diff --git a/docs-web/architecture/chat-connectors/discord.md b/docs-web/architecture/chat-connectors/discord.md index 73e9be2533..b3d57e7327 100644 --- a/docs-web/architecture/chat-connectors/discord.md +++ b/docs-web/architecture/chat-connectors/discord.md @@ -1,46 +1,5 @@ # Discord Connector Profile -Discord has two independently selected transports. The existing `webhook` mode preserves custom bot/gateway URLs and stored connection compatibility. The provider-native `official_api` mode owns Discord HTTP interaction authentication, Gateway v10 message delivery, REST replies, and read-only credential verification. +Discord is registered only with the existing `webhook` bot/gateway transport. Its module owns the unchanged setup schema, message normalizer, authentication headers and signature bases, outbound gateway mapping, response parsing, verification metadata, and session requirement. -## Official configuration and trust boundary - -The official setup requires an application ID, the application's hexadecimal Ed25519 public key, a Gateway intents bitfield, and a write-only bot token. The default bitfield is `37377`: `GUILDS`, `GUILD_MESSAGES`, `DIRECT_MESSAGES`, and privileged `MESSAGE_CONTENT`. Operators must enable `MESSAGE_CONTENT` in the Discord Developer Portal to receive ordinary message bodies. - -Official REST requests are pinned to `https://discord.com/api/v10`. Gateway and resume URLs must use secure Discord-owned `discord.gg` hosts. Values retained for legacy webhook connections cannot redirect official traffic. Bot tokens, interaction tokens, and authorization headers are excluded from persisted Gateway state and delivery metadata. - -## Interaction ingress - -The profile validates `X-Signature-Ed25519` against `X-Signature-Timestamp` plus the exact raw body before JSON parsing. Missing or malformed headers, malformed keys, stale timestamps, invalid signatures, malformed JSON, and unsupported interaction shapes produce deterministic classified failures. Authenticated type-1 validation requests receive the required JSON PONG response. - -`ChatProviderIngressSecurity` invokes the optional provider-native hook before generic bearer/HMAC handling. The production ingress route returns an immediate authenticated handshake response when present and otherwise continues into the existing ingress service. - -Application command, component, and modal payloads normalize into stable external channel, sender, interaction-message, and Discord thread identities. Gateway `MESSAGE_CREATE` payloads use the same normalized contract. - -## Gateway state machine - -`DiscordGatewaySession` is transport- and persistence-neutral. It receives injected WebSocket, timer, delay, and session-store boundaries so unit tests are completely offline. - -The state machine implements: - -- `Identify` and `Resume` payloads for Gateway v10; -- latest dispatch sequence tracking; -- first-heartbeat jitter, recurring heartbeats, ACK tracking, and immediate reconnect after a missed ACK; -- persistence of only `sessionId`, Discord resume URL, sequence, and bot user ID; -- resume after recoverable closes and re-identify after invalid sequence, timed-out session, or non-resumable invalid-session responses; -- bounded exponential reconnect backoff; -- terminal classifications for invalid auth, invalid intents, missing privileged intent access, shard errors, and unsupported Gateway versions; -- cancellation and clean shutdown that stop timers, close the socket, and prevent reconnects. - -The bot user ID from `READY` suppresses the connector's own `MESSAGE_CREATE` events without suppressing messages from unrelated bot accounts. - -## REST replies and verification - -Message creation disables all automatic mentions with `allowed_mentions.parse: []`, supplies a stable delivery nonce with `enforce_nonce`, preserves reply references, and accepts only snowflake message IDs from successful responses. Route/global reset headers and `Retry-After` are honored with one bounded immediate 429 retry; further rate limits are returned to the outer delivery scheduler. - -The configured outbound adapter caches the profile's official executor, preserving Discord rate-limit state across production deliveries. Profiles without an executor, including Discord `webhook`, retain the generic HTTP or command path. - -Credential verification performs only `GET /users/@me`. Typed results distinguish invalid authentication, missing permissions, rate limiting, timeout, cancellation, ambiguous network outcomes, provider unavailability, and invalid responses without retaining token-bearing messages. - -The profile remains side-effect free when the registry is constructed. Network and Gateway work begins only when the corresponding runtime client or session is explicitly started. - -References: [Gateway](https://docs.discord.com/developers/events/gateway), [Gateway events](https://docs.discord.com/developers/events/gateway-events), [Interactions](https://docs.discord.com/developers/interactions/overview), [Messages](https://docs.discord.com/developers/resources/message), and [Rate limits](https://docs.discord.com/developers/topics/rate-limits). +The baseline profile has no live test and does not advertise managed, native, or `official_api` modes. Unsupported combinations fail before network or process execution. diff --git a/docs-web/architecture/chat-connectors/index.md b/docs-web/architecture/chat-connectors/index.md index f0ebcc5ee3..2f06bbf7b4 100644 --- a/docs-web/architecture/chat-connectors/index.md +++ b/docs-web/architecture/chat-connectors/index.md @@ -4,7 +4,7 @@ Code UX registers one typed, independently editable profile for each external ch The registry is static and side-effect free. Network requests and native command execution remain in service-layer facades. Lookup fails closed when a provider or provider/mode combination is not registered. -The additive `official_api` mode is implemented by WhatsApp, Telegram, Slack, Microsoft Teams, and Discord without changing the persisted meaning of `managed_bridge`, `webhook`, or `native_bridge`. Profiles advertise only implemented modes. +The additive `official_api` mode is available to future profiles without changing the persisted meaning of `managed_bridge`, `webhook`, or `native_bridge`. Baseline profiles advertise only implemented modes. ## Provider Profiles diff --git a/docs-web/architecture/chat-connectors/microsoft-teams.md b/docs-web/architecture/chat-connectors/microsoft-teams.md index 982a2a8b8f..5d54965091 100644 --- a/docs-web/architecture/chat-connectors/microsoft-teams.md +++ b/docs-web/architecture/chat-connectors/microsoft-teams.md @@ -1,47 +1,5 @@ # Microsoft Teams Connector Profile -The Microsoft Teams connector retains the `managed_bridge` and custom `webhook` transports and adds a direct `official_api` profile based on Microsoft Bot Connector Activities. +Microsoft Teams is registered with `managed_bridge` and `webhook` transports. Its module owns the unchanged setup schemas, Bot Framework activity normalizer, ingress authentication metadata, outbound request mapping, response parsing, configuration verification, and official reference metadata. -## Configuration contract - -`official_api` stores the non-secret Microsoft app ID, `MultiTenant` or `SingleTenant` application type, and optional tenant ID in connection setup. The client secret is a required write-only credential. Single-tenant configurations require a tenant ID; a tenant ID on a multi-tenant configuration acts as an inbound tenant restriction. - -No setup field accepts an official Connector service URL. The service URL is learned only from an authenticated Activity and stored in its conversation reference after claim and host validation. - -## Authentication boundary - -`MicrosoftBotAuthService` owns provider-specific trust and transport behavior: - -1. Read a Bearer JWT from the request header and require `RS256`. -2. Load Microsoft's fixed Bot Connector OpenID metadata and JWKS documents, validate their fixed issuer/JWKS/algorithm contract, and cache signing keys for at most 24 hours. -3. Refresh once within a bounded interval when a previously unseen key ID indicates rotation. -4. Verify the RSA signature, issuer, app-ID audience, `nbf`/`iat`/`exp` window with five-minute skew, exact Activity/JWT service URL match, channel endorsement, and configured tenant. -5. Accept only HTTPS service URLs on the documented public, GCC, GCC High, and DoD Teams Connector host allowlist; arbitrary `*.botframework.com` subdomains are rejected. - -There is no insecure mode for disabling signature, claim, endorsement, tenant, or service URL validation. Authentication output contains the Activity, normalized message, and a durable conversation reference, but never the Bearer JWT or signing key. - -## Activity mapping - -Only `message` Activities enter chat ingestion. Unsupported types fail before delivery or conversation-message creation. The normalizer removes the bot recipient's mention entity from visible text while leaving other mentions intact and preserves: - -- Activity, conversation, and reply IDs; -- locale and Bot Framework channel ID; -- tenant, team, and Teams channel IDs; -- original sender, bot recipient, and conversation account; and -- an authenticated `serviceUrlValidated: true` conversation reference. - -The conversation reference is safe to persist with delivery metadata because it contains routing identities and the validated URL, not access tokens, client secrets, JWTs, or signing keys. - -## Outbound transport - -The service acquires app-only OAuth tokens from Microsoft's documented v2 client-credential endpoint. Multi-tenant apps use the `botframework.com` authority and single-tenant apps use their configured tenant. Tokens request `https://api.botframework.com/.default`, remain memory-only, and expire from the cache before their provider expiry. - -Replies are posted with the token to the persisted reference's validated service URL at `/v3/conversations/{conversationId}/activities/{activityId}`. The reply Activity swaps the original sender/recipient, retains conversation and locale, and sets `replyToId` to the triggering Activity ID. IDs are path-encoded, request timeouts are bounded, and arbitrary setup URLs never participate in official transport. - -## Diagnostics and local verification - -Diagnostics have stable categories for app identity, token acquisition, OpenID metadata, JWKS retrieval, tenant mismatch, expired signing keys, unusable signing-key sets, throttling, and unavailable or timed-out Microsoft services. Signing metadata is healthy only when at least one active RSA/RS256 verification key is importable and endorses `msteams`. Retryability is explicit and upstream bodies are not exposed as credential-bearing diagnostics. - -Microsoft offers Bot Framework Emulator and Microsoft 365 Agents/Teams development tooling for local bot testing, not a public unauthenticated sandbox. The automated suite therefore uses local RSA keys plus mocked OpenID, JWKS, OAuth, and Connector responses, including Emulator-shaped Activity fixtures, without contacting Microsoft tenant services. - -References: [Bot Connector authentication](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0), [send and receive messages](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-send-and-receive-messages?view=azure-bot-service-4.0), [Activity protocol](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/activity-protocol), and [local bot testing](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/debug/locally-with-an-ide). +The baseline profile has no live test and does not implement `official_api`. Registry construction never contacts Microsoft services. diff --git a/docs-web/architecture/external-chat-providers.md b/docs-web/architecture/external-chat-providers.md index f6662a1dcd..7e3c48fe88 100644 --- a/docs-web/architecture/external-chat-providers.md +++ b/docs-web/architecture/external-chat-providers.md @@ -24,7 +24,7 @@ The bridge-mode type includes `managed_bridge`, `webhook`, `native_bridge`, and - Microsoft Teams: managed bridge or bot webhook. - Discord: bot/webhook gateway. -Public records expose redacted credential metadata only. Runtime code that needs secrets must call the explicit internal repository read path. +Public records expose redacted credential metadata only. Runtime code that needs secrets resolves an ephemeral connection profile through `ChatProviderSecretService`; repository reads never decrypt connector credentials. Profiles declare setup, authentication and handshake behavior, normalization, external identity, outbound mapping and parsing, verification, session requirements, official references, live-test availability, and lifecycle metadata. The registry itself is side-effect free; network and process execution stay in shared runtime services. See the [Chat Connector Registry](./chat-connectors/index.md) and its provider pages. @@ -52,9 +52,12 @@ SQLite tables are created for fresh databases and during startup migrations for | Table | Purpose | | --- | --- | -| `chat_provider_connections` | Provider kind, bridge mode, status, enabled flag, setup JSON, and secret JSON. | +| `chat_provider_connections` | Provider kind, bridge mode, status, enabled flag, setup JSON, sanitized verification results, and connector secret version. The nullable `secret_json` column is retained only as a legacy migration source. | +| `chat_provider_connection_secrets` | AES-256-GCM envelope fields, root-key id/version, and non-secret configured-field metadata. | | `chat_provider_channel_bindings` | Links external channels to projects with routing hints, optional project-manager agent preset, inbound/outbound flags, and `suppress_rich_widgets` defaulting to true. | -| `chat_provider_message_deliveries` | Inbound idempotency keys and outbound delivery status, attempts, errors, linked conversation message IDs, and payload snapshots. | +| `chat_provider_message_deliveries` | Inbound idempotency keys plus outbound status, attempts, explicit retry schedule, compare-and-set lease ownership, linked conversation IDs, and payload snapshots. | +| `chat_provider_ingress_replay_receipts` | Expiring authenticated-ingress replay receipts, unique per connection and replay key. | +| `chat_provider_sessions` | Resumable provider-native session state with connection/binding ownership and compare-and-set versions. | Bindings allow many projects to point at the same external channel and one project to use multiple channels. Provider deletion cascades bindings and delivery rows. Existing MCP connection and conversation tables remain unchanged. @@ -64,11 +67,13 @@ Bindings allow many projects to point at the same external channel and one proje - Connection create/update/list/get/delete. - Redacted public reads and unredacted internal reads. -- Secret-preserving updates when an update omits the `secrets` field. +- Atomic encrypted-envelope create, rotation, and clearing in the same secret-version CAS transaction as connection metadata, plus resumable post-key-readiness sealing of legacy plaintext. +- Verification reset after authentication, transport, enabled/status, or setup changes while display-name-only edits preserve the last result. - Channel binding create/update/list/get/delete. -- Inbound duplicate lookup by `(providerConnectionId, externalMessageId)`. +- Atomic inbound duplicate insertion by `(providerConnectionId, externalMessageId)` and atomic expiring replay-receipt insertion. +- Compare-and-set provider session updates and expiry cleanup. - Outbound delivery upsert and state transitions. -- Outbound delivery listing scoped to a provider connection or channel binding for dashboard status views, plus pending/retryable outbound delivery scans for retry workers. +- Outbound delivery listing plus lease claim/complete/release operations with due-time filtering and stale-lease recovery. Indexes cover provider kind, enabled status, project lookup, provider/channel lookup, inbound dedupe, and pending/retryable outbound delivery scans. @@ -98,10 +103,10 @@ Outbound delivery lifecycle: - `pending`: reply has been persisted and queued for bridge delivery. - `sending`: an adapter attempt is in progress. - `delivered`: the bridge accepted the reply; `externalMessageId` is stored when the bridge returns one. -- `retryable_failure`: a retryable bridge failure occurred and the payload contains `delivery.nextAttemptAt`. +- `retryable_failure`: a retryable bridge failure occurred and `next_attempt_at` records the durable schedule (the redacted payload mirrors it for display). - `failed`: delivery is terminal, such as disabled outbound routing, missing bridge configuration, non-retryable HTTP response, or exhausted attempts. -Retryable HTTP/network/native bridge failures use exponential backoff. The dashboard lifecycle starts the outbound retry loop, and status APIs/MCP reads expose delivery status, attempt count, last error, linked conversation message id, and redacted payload state. Secrets are redacted from logs, payloads, stored errors, dashboard responses, and MCP responses. +Retryable HTTP/network/native bridge failures use exponential backoff. Retry workers acquire bounded delivery leases before network or native command execution; competing workers cannot claim the same row, and expired leases are recoverable after a crash. The dashboard lifecycle starts the outbound retry loop, and status APIs/MCP reads expose delivery status, attempt count, last error, linked conversation message id, and redacted payload state. Secrets are redacted from logs, payloads, stored errors, dashboard responses, and MCP responses. ## Dashboard API @@ -134,7 +139,7 @@ Channel binding controls support multiple projects on the same external channel Provider cards and connection detail views surface enabled state, bridge mode, ingress URL, authentication status, configured channels, bound projects, outbound reply state, pending outbound delivery count, and failed outbound delivery count. Recent failed outbound messages are shown with retryable labels and redacted error text. -The ingress endpoint supports Managed, webhook, and native bridge payloads for WhatsApp, iMessage, Telegram, Slack, Microsoft Teams, and Discord. Managed and native bridges authenticate with bearer tokens from the configured bridge secret. Webhook bridges require a configured signing secret and a valid HMAC signature; they do not accept bearer-only fallback. All ingress requests require a fresh timestamp, and signed requests or requests with explicit nonces are replay-checked before processing. +The ingress endpoint supports Managed, webhook, and native bridge payloads for WhatsApp, iMessage, Telegram, Slack, Microsoft Teams, and Discord. Managed and native bridges authenticate with bearer tokens resolved ephemerally from the encrypted envelope. Webhook bridges require a configured signing secret and a valid HMAC signature; they do not accept bearer-only fallback. All ingress requests require a fresh timestamp, and signed requests or requests with explicit nonces are atomically replay-checked through expiring SQLite receipts before processing. Inbound messages normalize to provider connection id, provider kind, external channel id/name, external sender id/name, text, external message id, timestamp, and redacted raw metadata. The repository idempotency lookup runs before chat posting; duplicate external messages return the existing delivery record without creating another conversation message. diff --git a/docs-web/content/docs/architecture-external-chat-providers.mdx b/docs-web/content/docs/architecture-external-chat-providers.mdx index e0defa792a..6ffd3cc58a 100644 --- a/docs-web/content/docs/architecture-external-chat-providers.mdx +++ b/docs-web/content/docs/architecture-external-chat-providers.mdx @@ -24,7 +24,7 @@ The bridge-mode type includes `managed_bridge`, `webhook`, `native_bridge`, and - Microsoft Teams: managed bridge or bot webhook. - Discord: bot/webhook gateway. -Public records expose redacted credential metadata only. Runtime code that needs secrets must call the explicit internal repository read path. +Public records expose redacted credential metadata only. Runtime code that needs secrets resolves an ephemeral connection profile through `ChatProviderSecretService`; repository reads never decrypt connector credentials. Profiles declare setup, authentication and handshake behavior, normalization, external identity, outbound mapping and parsing, verification, session requirements, official references, live-test availability, and lifecycle metadata. The registry itself is side-effect free; network and process execution stay in shared runtime services. See the [Chat Connector Registry](/docs/architecture-chat-connectors-overview) and its provider pages. @@ -52,9 +52,12 @@ SQLite tables are created for fresh databases and during startup migrations for | Table | Purpose | | --- | --- | -| `chat_provider_connections` | Provider kind, bridge mode, status, enabled flag, setup JSON, and secret JSON. | +| `chat_provider_connections` | Provider kind, bridge mode, status, enabled flag, setup JSON, sanitized verification results, and connector secret version. The nullable `secret_json` column is retained only as a legacy migration source. | +| `chat_provider_connection_secrets` | AES-256-GCM envelope fields, root-key id/version, and non-secret configured-field metadata. | | `chat_provider_channel_bindings` | Links external channels to projects with routing hints, optional project-manager agent preset, inbound/outbound flags, and `suppress_rich_widgets` defaulting to true. | -| `chat_provider_message_deliveries` | Inbound idempotency keys and outbound delivery status, attempts, errors, linked conversation message IDs, and payload snapshots. | +| `chat_provider_message_deliveries` | Inbound idempotency keys plus outbound status, attempts, explicit retry schedule, compare-and-set lease ownership, linked conversation IDs, and payload snapshots. | +| `chat_provider_ingress_replay_receipts` | Expiring authenticated-ingress replay receipts, unique per connection and replay key. | +| `chat_provider_sessions` | Resumable provider-native session state with connection/binding ownership and compare-and-set versions. | Bindings allow many projects to point at the same external channel and one project to use multiple channels. Provider deletion cascades bindings and delivery rows. Existing MCP connection and conversation tables remain unchanged. @@ -64,11 +67,13 @@ Bindings allow many projects to point at the same external channel and one proje - Connection create/update/list/get/delete. - Redacted public reads and unredacted internal reads. -- Secret-preserving updates when an update omits the `secrets` field. +- Atomic encrypted-envelope create, rotation, and clearing in the same secret-version CAS transaction as connection metadata, plus resumable post-key-readiness sealing of legacy plaintext. +- Verification reset after authentication, transport, enabled/status, or setup changes while display-name-only edits preserve the last result. - Channel binding create/update/list/get/delete. -- Inbound duplicate lookup by `(providerConnectionId, externalMessageId)`. +- Atomic inbound duplicate insertion by `(providerConnectionId, externalMessageId)` and atomic expiring replay-receipt insertion. +- Compare-and-set provider session updates and expiry cleanup. - Outbound delivery upsert and state transitions. -- Outbound delivery listing scoped to a provider connection or channel binding for dashboard status views, plus pending/retryable outbound delivery scans for retry workers. +- Outbound delivery listing plus lease claim/complete/release operations with due-time filtering and stale-lease recovery. Indexes cover provider kind, enabled status, project lookup, provider/channel lookup, inbound dedupe, and pending/retryable outbound delivery scans. @@ -98,10 +103,10 @@ Outbound delivery lifecycle: - `pending`: reply has been persisted and queued for bridge delivery. - `sending`: an adapter attempt is in progress. - `delivered`: the bridge accepted the reply; `externalMessageId` is stored when the bridge returns one. -- `retryable_failure`: a retryable bridge failure occurred and the payload contains `delivery.nextAttemptAt`. +- `retryable_failure`: a retryable bridge failure occurred and `next_attempt_at` records the durable schedule (the redacted payload mirrors it for display). - `failed`: delivery is terminal, such as disabled outbound routing, missing bridge configuration, non-retryable HTTP response, or exhausted attempts. -Retryable HTTP/network/native bridge failures use exponential backoff. The dashboard lifecycle starts the outbound retry loop, and status APIs/MCP reads expose delivery status, attempt count, last error, linked conversation message id, and redacted payload state. Secrets are redacted from logs, payloads, stored errors, dashboard responses, and MCP responses. +Retryable HTTP/network/native bridge failures use exponential backoff. Retry workers acquire bounded delivery leases before network or native command execution; competing workers cannot claim the same row, and expired leases are recoverable after a crash. The dashboard lifecycle starts the outbound retry loop, and status APIs/MCP reads expose delivery status, attempt count, last error, linked conversation message id, and redacted payload state. Secrets are redacted from logs, payloads, stored errors, dashboard responses, and MCP responses. ## Dashboard API @@ -134,7 +139,7 @@ Channel binding controls support multiple projects on the same external channel Provider cards and connection detail views surface enabled state, bridge mode, ingress URL, authentication status, configured channels, bound projects, outbound reply state, pending outbound delivery count, and failed outbound delivery count. Recent failed outbound messages are shown with retryable labels and redacted error text. -The ingress endpoint supports Managed, webhook, and native bridge payloads for WhatsApp, iMessage, Telegram, Slack, Microsoft Teams, and Discord. Managed and native bridges authenticate with bearer tokens from the configured bridge secret. Webhook bridges require a configured signing secret and a valid HMAC signature; they do not accept bearer-only fallback. All ingress requests require a fresh timestamp, and signed requests or requests with explicit nonces are replay-checked before processing. +The ingress endpoint supports Managed, webhook, and native bridge payloads for WhatsApp, iMessage, Telegram, Slack, Microsoft Teams, and Discord. Managed and native bridges authenticate with bearer tokens resolved ephemerally from the encrypted envelope. Webhook bridges require a configured signing secret and a valid HMAC signature; they do not accept bearer-only fallback. All ingress requests require a fresh timestamp, and signed requests or requests with explicit nonces are atomically replay-checked through expiring SQLite receipts before processing. Inbound messages normalize to provider connection id, provider kind, external channel id/name, external sender id/name, text, external message id, timestamp, and redacted raw metadata. The repository idempotency lookup runs before chat posting; duplicate external messages return the existing delivery record without creating another conversation message. diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx index 3c0104b783..8fcc635b5b 100644 --- a/docs-web/content/docs/operations-credential-security.mdx +++ b/docs-web/content/docs/operations-credential-security.mdx @@ -25,6 +25,8 @@ Authorization is rechecked after decryption. Concurrent revocation, rotation, re The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys. +Chat connector credentials use the same key-provider and envelope-encryption boundary in `chat_provider_connection_secrets`. Dashboard and MCP writes seal before a single secret-version CAS transaction commits connection metadata and creates, replaces, or clears the envelope; provider profiles receive decrypted values only for the active ingress or outbound operation, and public/repository reads expose configured field names with redacted values. Startup performs an idempotent post-readiness migration of legacy `secret_json` rows one connection at a time; a failed seal leaves that row unchanged and a later startup safely resumes it. + Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owner-only mounted file containing an exact base64 or hexadecimal encoding of a 32-byte key. Electron serializes first-use key creation and atomically persists only the OS-protected blob. Vault and KMS adapters validate key material and report the active key id/version. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. ## Recovery and rotation diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md index 3c0104b783..8fcc635b5b 100644 --- a/docs-web/operations/credential-security.md +++ b/docs-web/operations/credential-security.md @@ -25,6 +25,8 @@ Authorization is rechecked after decryption. Concurrent revocation, rotation, re The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys. +Chat connector credentials use the same key-provider and envelope-encryption boundary in `chat_provider_connection_secrets`. Dashboard and MCP writes seal before a single secret-version CAS transaction commits connection metadata and creates, replaces, or clears the envelope; provider profiles receive decrypted values only for the active ingress or outbound operation, and public/repository reads expose configured field names with redacted values. Startup performs an idempotent post-readiness migration of legacy `secret_json` rows one connection at a time; a failed seal leaves that row unchanged and a later startup safely resumes it. + Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owner-only mounted file containing an exact base64 or hexadecimal encoding of a 32-byte key. Electron serializes first-use key creation and atomically persists only the OS-protected blob. Vault and KMS adapters validate key material and report the active key id/version. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. ## Recovery and rotation diff --git a/docs/architecture/external-chat-providers.md b/docs/architecture/external-chat-providers.md index d62e7d9415..455d6f8548 100644 --- a/docs/architecture/external-chat-providers.md +++ b/docs/architecture/external-chat-providers.md @@ -24,7 +24,7 @@ The bridge-mode type includes `managed_bridge`, `webhook`, `native_bridge`, and - Microsoft Teams: managed bridge or bot webhook. - Discord: bot/webhook gateway. -Public records expose redacted credential metadata only. Runtime code that needs secrets must call the explicit internal repository read path. +Public records expose redacted credential metadata only. Runtime code that needs secrets resolves an ephemeral connection profile through `ChatProviderSecretService`; repository reads never decrypt connector credentials. Profiles also declare authentication and handshake behavior, normalization, external identity, outbound construction and parsing, verification and session requirements, official references, live-test availability, and lifecycle metadata. Registry construction is side-effect free; shared service facades retain HTTP, command execution, redaction, replay, and timing-safe comparison responsibilities. @@ -54,9 +54,12 @@ SQLite tables are created by `APP_DB_SCHEMA_TABLES` for fresh databases and by ` | Table | Purpose | | --- | --- | -| `chat_provider_connections` | Provider kind, bridge mode, status, enabled flag, setup JSON, and secret JSON. | +| `chat_provider_connections` | Provider kind, bridge mode, status, enabled flag, setup JSON, sanitized verification results, and connector secret version. The nullable `secret_json` column is retained only as a legacy migration source. | +| `chat_provider_connection_secrets` | AES-256-GCM envelope fields, root-key id/version, and non-secret configured-field metadata. | | `chat_provider_channel_bindings` | Links external channels to projects with routing hints, optional project-manager agent preset, inbound/outbound flags, and `suppress_rich_widgets` defaulting to true. | -| `chat_provider_message_deliveries` | Inbound idempotency keys and outbound delivery status, attempts, errors, linked conversation message IDs, and payload snapshots. | +| `chat_provider_message_deliveries` | Inbound idempotency keys plus outbound status, attempts, explicit retry schedule, compare-and-set lease ownership, linked conversation IDs, and payload snapshots. | +| `chat_provider_ingress_replay_receipts` | Expiring authenticated-ingress replay receipts, unique per connection and replay key. | +| `chat_provider_sessions` | Resumable provider-native session state with connection/binding ownership and compare-and-set versions. | Bindings allow many projects to point at the same external channel and one project to use multiple channels. Provider deletion cascades bindings and delivery rows. Existing `mcp_connections`, `conversation_threads`, and `conversation_messages` behavior remains unchanged. @@ -66,11 +69,13 @@ Bindings allow many projects to point at the same external channel and one proje - Connection create/update/list/get/delete. - Redacted public reads and unredacted internal reads. -- Secret-preserving updates when an update omits the `secrets` field. +- Atomic encrypted-envelope create, rotation, and clearing in the same secret-version CAS transaction as connection metadata, plus resumable post-key-readiness sealing of legacy plaintext. +- Verification reset after authentication, transport, enabled/status, or setup changes while display-name-only edits preserve the last result. - Channel binding create/update/list/get/delete. -- Inbound duplicate lookup by `(providerConnectionId, externalMessageId)`. +- Atomic inbound duplicate insertion by `(providerConnectionId, externalMessageId)` and atomic expiring replay-receipt insertion. +- Compare-and-set provider session updates and expiry cleanup. - Outbound delivery upsert and state transitions. -- Outbound delivery listing scoped to a provider connection or channel binding for dashboard status views, plus pending/retryable outbound delivery scans for retry workers. +- Outbound delivery listing plus lease claim/complete/release operations with due-time filtering and stale-lease recovery. Indexes cover provider kind, enabled status, project lookup, provider/channel lookup, inbound dedupe, and pending/retryable outbound delivery scans. @@ -100,10 +105,10 @@ Outbound delivery lifecycle: - `pending`: reply has been persisted and queued for bridge delivery. - `sending`: an adapter attempt is in progress. - `delivered`: the bridge accepted the reply; `externalMessageId` is stored when the bridge returns one. -- `retryable_failure`: a retryable bridge failure occurred and the payload contains `delivery.nextAttemptAt`. +- `retryable_failure`: a retryable bridge failure occurred and `next_attempt_at` records the durable schedule (the redacted payload mirrors it for display). - `failed`: delivery is terminal, such as disabled outbound routing, missing bridge configuration, non-retryable HTTP response, or exhausted attempts. -Retryable HTTP/network/native bridge failures use exponential backoff. The dashboard lifecycle starts the outbound retry loop, and status APIs/MCP reads expose delivery status, attempt count, last error, linked conversation message id, and redacted payload state. Secrets are redacted from logs, payloads, stored errors, dashboard responses, and MCP responses. +Retryable HTTP/network/native bridge failures use exponential backoff. Retry workers acquire bounded delivery leases before network or native command execution; competing workers cannot claim the same row, and expired leases are recoverable after a crash. The dashboard lifecycle starts the outbound retry loop, and status APIs/MCP reads expose delivery status, attempt count, last error, linked conversation message id, and redacted payload state. Secrets are redacted from logs, payloads, stored errors, dashboard responses, and MCP responses. ## Dashboard API @@ -136,7 +141,7 @@ Channel binding controls support multiple projects on the same external channel Provider cards and connection detail views surface enabled state, bridge mode, ingress URL, authentication status, configured channels, bound projects, outbound reply state, pending outbound delivery count, and failed outbound delivery count. Recent failed outbound messages are shown with retryable labels and redacted error text. -The ingress endpoint supports Managed, webhook, and native bridge payloads for WhatsApp, iMessage, Telegram, Slack, Microsoft Teams, and Discord. Managed and native bridges authenticate with bearer tokens from the configured bridge secret. Webhook bridges require a configured signing secret and a valid HMAC signature; they do not accept bearer-only fallback. All ingress requests require a fresh timestamp, and signed requests or requests with explicit nonces are replay-checked before processing. +The ingress endpoint supports Managed, webhook, and native bridge payloads for WhatsApp, iMessage, Telegram, Slack, Microsoft Teams, and Discord. Managed and native bridges authenticate with bearer tokens resolved ephemerally from the encrypted envelope. Webhook bridges require a configured signing secret and a valid HMAC signature; they do not accept bearer-only fallback. All ingress requests require a fresh timestamp, and signed requests or requests with explicit nonces are atomically replay-checked through expiring SQLite receipts before processing. Inbound messages normalize to provider connection id, provider kind, external channel id/name, external sender id/name, text, external message id, timestamp, and redacted raw metadata. The repository idempotency lookup runs before chat posting; duplicate external messages return the existing delivery record without creating another conversation message. diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index 787f9cf673..1889c83843 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -25,6 +25,8 @@ Resolution authorization is checked both before and after decryption. If a crede The SQLite secret store uses AES-256-GCM envelope encryption. Each write generates a unique 256-bit data key, payload nonce, and key-wrapping nonce. Credential ownership and workspace context are authenticated as additional data. SQLite stores only ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions. +Chat connector credentials use the same key-provider and envelope-encryption boundary in `chat_provider_connection_secrets`. Dashboard and MCP writes seal before a single secret-version CAS transaction commits connection metadata and creates, replaces, or clears the envelope; provider profiles receive decrypted values only for the active ingress or outbound operation, and public/repository reads expose configured field names with redacted values. Startup performs an idempotent post-readiness migration of legacy `secret_json` rows one connection at a time; a failed seal leaves that row unchanged and a later startup safely resumes it. + Root keys are never stored in SQLite. Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to identify a regular, owner-only mounted file whose contents are an exact base64 or hexadecimal encoding of 32 bytes. Oversized or permissively decodable key files are rejected. The environment variable contains a path, not key material. Keep the mount readable only by the Code UX process and outside the project workspace. Electron serializes first-use root-key creation, persists only the OS-protected blob through an atomic owner-only file replacement, and refuses credential operations when `safeStorage` is unavailable. Vault and KMS adapters validate 32-byte caller-owned key material and report the active key id/version in health results. No provider silently falls back to plaintext or an insecure locally derived key. diff --git a/docs/settings/chat-connectors/discord.md b/docs/settings/chat-connectors/discord.md index cfe81c3004..5d88adde50 100644 --- a/docs/settings/chat-connectors/discord.md +++ b/docs/settings/chat-connectors/discord.md @@ -1,72 +1,9 @@ # Discord Chat Connector -Discord supports the existing `webhook` bridge and a provider-native `official_api` mode. Existing stored webhook connections keep their current setup and routing behavior; changing to `official_api` is explicit. +The baseline Discord profile supports only `webhook`, matching the existing bot/webhook gateway contract. It normalizes Discord message payloads and deliberately does not advertise managed, native, or direct official API delivery. -## Setup modes +Setup remains compatible with stored connections: optional `gatewayUrl` and `applicationId`, required `botToken`, and optional `webhookSecret`. A bot or gateway session owns provider event delivery. -### Official API +Live provider testing is not implemented by this baseline profile. -Configure these values from the Discord Developer Portal: - -- **Application ID**: the Discord application snowflake. -- **Interactions public key**: the 32-byte hexadecimal Ed25519 public key. This is public configuration, not a bot credential. -- **Gateway intents bitfield**: defaults to `37377` (`GUILDS`, `GUILD_MESSAGES`, `DIRECT_MESSAGES`, and `MESSAGE_CONTENT`). -- **Bot token**: a required write-only secret. Code UX redacts it from connection responses and never stores it in Gateway session state or delivery metadata. - -`MESSAGE_CONTENT` is a privileged Gateway intent. Enable it on the application's **Bot** page in the Developer Portal before starting the connector. Without it, ordinary `MESSAGE_CREATE` events may omit `content`; Discord can close the Gateway with code `4014` when a privileged intent is requested without access. - -The official connector controls its network destinations. REST calls use only `https://discord.com/api/v10`, and Gateway connections use Discord-owned `wss://*.discord.gg` hosts. A saved `gatewayUrl`, webhook URL, bridge URL, or an untrusted resume URL cannot replace those origins in `official_api` mode. - -### Webhook compatibility - -The `webhook` mode retains the stored bot/webhook gateway contract: - -- optional `gatewayUrl` and `applicationId` -- required `botToken` -- optional `webhookSecret` - -Outbound requests continue to use the configured custom gateway URL and the shared legacy bridge authentication and response parsing behavior. - -## HTTP interactions - -Discord signs each HTTP interaction with `X-Signature-Ed25519` and `X-Signature-Timestamp`. Code UX verifies the signature over the timestamp concatenated with the exact raw request body, checks a five-minute freshness window, and rejects missing, malformed, stale, or mismatched authentication with a deterministic `400` or `401` result. Parsing or reserializing JSON before signature verification is not safe because it changes the signed bytes. - -An authenticated interaction with `type: 1` receives HTTP `200`, JSON content type, and `{ "type": 1 }` as required by Discord's endpoint validation. Supported command, component, and modal interactions normalize to stable Discord channel, sender, interaction-message, and thread identities. - -The normal chat-provider ingress route invokes this provider-native verification before acknowledgement or message routing. PING requests stop at the handshake response; other authenticated interactions continue through the existing binding, idempotency, and conversation delivery path. - -## Gateway delivery - -Official message delivery uses Gateway v10 with JSON encoding. A connection: - -1. waits for `Hello`, starts the first heartbeat at Discord's randomized jitter offset, and sends `Identify`; -2. records every dispatch sequence and persists only `session_id`, `resume_gateway_url`, sequence, and the bot user ID; -3. sends `Resume` after resumable disconnects and falls back to `Identify` after invalid or expired sessions; -4. reconnects when a heartbeat is not acknowledged, using bounded exponential backoff; -5. stops all heartbeat and reconnect work on cancellation or shutdown. - -Only `MESSAGE_CREATE` dispatches are normalized for chat ingress. Messages authored by the connected bot user are ignored to prevent reply loops. - -## Replies and rate limits - -Replies use `POST /channels/{channel.id}/messages` on API v10. Code UX always sends `allowed_mentions.parse: []`, a stable delivery nonce with `enforce_nonce: true`, and a Discord `message_reference` when replying to an inbound message. Returned message IDs must be valid snowflakes before they are recorded. - -The client tracks route and global rate-limit headers, waits for Discord's `Retry-After` or reset interval, and performs at most one immediate 429 retry. Persistent rate limiting returns a retryable classified failure to the shared delivery scheduler rather than creating an internal retry storm. - -The shared outbound service retains one Discord executor for official deliveries, so rate-limit state is reused across attempts. The custom `webhook` mode continues through the legacy configured-URL adapter. - -## Credential verification and failures - -Credential verification is read-only and calls only `GET https://discord.com/api/v10/users/@me`. Results distinguish: - -- invalid bot authentication (`401`) -- missing channel permissions (`403` during delivery) -- rate limiting (`429`) -- request timeout or cancellation -- ambiguous network outcome -- temporary provider failure and invalid provider responses -- invalid Gateway intents and unavailable privileged intents - -Errors and verification results use bounded, token-free messages. Live credential tests are separate from unit tests; deterministic unit fixtures use mocked HTTP and Gateway transports and never contact Discord. - -Official references: [Gateway](https://docs.discord.com/developers/events/gateway), [Gateway events](https://docs.discord.com/developers/events/gateway-events), [Interactions](https://docs.discord.com/developers/interactions/overview), [Messages](https://docs.discord.com/developers/resources/message), and [Rate limits](https://docs.discord.com/developers/topics/rate-limits). +Official references: [receiving interactions](https://docs.discord.com/developers/interactions/receiving-and-responding) and [message resources](https://docs.discord.com/developers/resources/message). diff --git a/docs/settings/chat-connectors/index.md b/docs/settings/chat-connectors/index.md index 2dc0c910e7..cd3cb7ba3e 100644 --- a/docs/settings/chat-connectors/index.md +++ b/docs/settings/chat-connectors/index.md @@ -2,7 +2,7 @@ Each supported external chat connector has an independently editable runtime profile. A profile owns its setup schema, implemented transport modes, ingress authentication and normalization, conversation identity rules, outbound mapping, verification capabilities, session requirements, official references, and lifecycle metadata. -The additive `official_api` bridge mode is implemented by WhatsApp, Telegram, Slack, Microsoft Teams, and Discord. A connector page lists only modes its profile implements; existing `managed_bridge`, `webhook`, and `native_bridge` records keep their established meaning. +The `official_api` bridge-mode type is reserved for additive provider implementations. A connector page lists only modes its baseline profile currently implements; existing `managed_bridge`, `webhook`, and `native_bridge` records keep their established meaning. ## Providers diff --git a/docs/settings/chat-connectors/microsoft-teams.md b/docs/settings/chat-connectors/microsoft-teams.md index d53789efd3..41540d8057 100644 --- a/docs/settings/chat-connectors/microsoft-teams.md +++ b/docs/settings/chat-connectors/microsoft-teams.md @@ -1,69 +1,9 @@ # Microsoft Teams Chat Connector -The Microsoft Teams profile supports three connection modes: +The baseline Microsoft Teams profile supports `managed_bridge` and `webhook`. It normalizes Bot Framework-style activity payloads and keeps delivery behind the configured managed or bot webhook bridge. -- `managed_bridge` keeps the existing managed-plugin contract (`pluginName`, optional `tenantId`, and write-only `bridgeApiKey`). -- `webhook` keeps the existing custom bot-webhook contract (`botEndpointUrl`, optional `tenantId`, write-only `botAppPassword`, and optional `webhookSecret`). -- `official_api` uses the Microsoft Bot Connector Activity and authentication protocols directly. +Setup remains compatible with stored connections: managed setup uses `pluginName` and optional `tenantId` with `bridgeApiKey`; webhook setup uses `botEndpointUrl`, optional `tenantId`, `botAppPassword`, and optional `webhookSecret`. -## Official API setup +Live provider testing and direct `official_api` transport are not implemented by this baseline profile. -Configure `official_api` with: - -| Setting | Required | Purpose | -| --- | --- | --- | -| `microsoftAppId` | Yes | Audience used for incoming Connector JWTs and client ID used for outgoing OAuth tokens. | -| `applicationType` | Yes | `MultiTenant` or `SingleTenant`. Defaults to `MultiTenant`. | -| `tenantId` | For `SingleTenant` | Selects the tenant OAuth endpoint and restricts accepted Teams Activities to that tenant. For a multi-tenant app, supplying it also acts as a tenant allowlist. | -| `clientSecret` | Yes, write-only | Microsoft Entra client secret used only for OAuth client-credential requests. | - -Do not enter a Bot Connector `serviceUrl` in setup. Code UX accepts a service URL only after it appears in a successfully authenticated Activity, exactly matches the JWT `serviceUrl` claim, and resolves to one of the documented Teams Connector hosts: public `smba.trafficmanager.net`, GCC `smba.infra.gcc.teams.microsoft.com`, GCC High `smba.infra.gov.teams.microsoft.us`, or DoD `smba.infra.dod.teams.microsoft.us`. Arbitrary `*.botframework.com` subdomains are not accepted. This prevents a connection record or inbound payload from selecting an arbitrary outbound host. - -## Incoming Activities - -The official path requires a Bearer JWT in the `Authorization` header. Validation fails closed unless all of these checks pass: - -- the token uses `RS256` and a key from Microsoft's fixed Bot Connector OpenID metadata and JWKS endpoints; -- issuer is `https://api.botframework.com` and audience is the configured Microsoft app ID; -- `nbf`, `iat` when present, and `exp` are valid with the documented five-minute clock skew; -- the JWT `serviceUrl` claim exactly matches the Activity `serviceUrl`; -- the signing key endorses the Activity's channel ID (`msteams` for Teams); -- the Activity tenant matches the configured tenant policy; and -- the service URL uses HTTPS and a documented Bot Framework/Teams Connector host. - -Signing keys are cached for no more than 24 hours. An unknown key ID can trigger one bounded refresh so normal Microsoft key rotation works without allowing unlimited JWKS fetches. - -Only `message` Activities create Code UX chat messages. `conversationUpdate`, `event`, `invoke`, `typing`, and unknown Activity types are rejected by the message normalizer. For accepted messages, Code UX removes the bot's own mention from user-visible text while preserving other mentions. It retains locale, tenant, team/channel, conversation, reply, sender/recipient, and the authenticated service URL in a durable conversation reference. Tokens, JWTs, client secrets, and signing keys are not copied into delivery payloads. - -## Replies and token caching - -For multi-tenant apps, Code UX requests a client-credential token from: - -```text -https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token -``` - -For single-tenant apps, `botframework.com` is replaced by the configured tenant ID. The scope is `https://api.botframework.com/.default`. Access tokens remain in memory and are reused only until a safe pre-expiry boundary; they are never persisted. - -Replies use the authenticated, persisted conversation reference and are sent to: - -```text -{validated-serviceUrl}/v3/conversations/{conversationId}/activities/{activityId} -``` - -The reply Activity swaps the original sender and recipient, preserves the conversation and locale, and sets `replyToId` to the incoming Activity ID. - -## Diagnostics - -Connection diagnostics return stable codes instead of Microsoft response bodies or credentials. They distinguish invalid app identity, token acquisition failure, OpenID metadata failure, JWKS failure, tenant mismatch, expired signing keys, unusable signing-key sets, Microsoft throttling, and unavailable/timeout conditions. The signing-metadata check succeeds only when at least one currently active RSA/RS256 verification key has importable public material and endorses `msteams`. HTTP `429` and transient Microsoft service failures are marked retryable; invalid identity, claims, signatures, endorsements, tenants, and service URLs are terminal until configuration or input changes. - -## Local testing - -Microsoft does not provide a public unauthenticated Bot Connector sandbox. Use Bot Framework Emulator or Microsoft 365 Agents/Teams local development tooling with mocked OpenID, JWKS, OAuth, and Connector boundaries for automated tests. Emulator-shaped Activities are useful normalization fixtures, but localhost is not trusted as an official Connector service URL and there is no switch that bypasses JWT, signature, endorsement, tenant, or service URL validation. - -Official references: - -- [Bot Connector authentication](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0) -- [Send and receive messages](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-send-and-receive-messages?view=azure-bot-service-4.0) -- [Activity protocol](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/activity-protocol) -- [Local bot testing](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/debug/locally-with-an-ide) +Official reference: [Teams conversational bots](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability). diff --git a/src/app/dependency-factory/core-factory.ts b/src/app/dependency-factory/core-factory.ts index c2f7322ae4..b4b5425fd1 100644 --- a/src/app/dependency-factory/core-factory.ts +++ b/src/app/dependency-factory/core-factory.ts @@ -80,6 +80,7 @@ import { HeadlessAuthService, loadHeadlessSecurityConfiguration } from "../../se import { AutomationAuditExportService } from "../../services/automation-audit-export-service.js"; import { HeadlessOperationalReadinessService } from "../../services/headless-operational-readiness-service.js"; import { AutomationSloService } from "../../services/automation-slo-service.js"; +import { ChatProviderSecretService } from "../../services/chat-provider-secret-service.js"; export interface CoreDependencies { providerRunner: IProviderRunner; @@ -98,6 +99,7 @@ export interface CoreDependencies { projectRuntimeRepository: ProjectRuntimeRepository; connectionChatRepository: ConnectionChatRepository; chatProviderRepository: ChatProviderRepository; + chatProviderSecretService: ChatProviderSecretService; workerEndpointRepository: WorkerEndpointRepository; projectWorkerAssignmentRepository: ProjectWorkerAssignmentRepository; qaReviewRepository: QaReviewRepository; @@ -256,6 +258,21 @@ export function createCoreDependencies( workerEndpointRepository, ); const chatProviderRepository = new ChatProviderRepository(appDbStorage); + const chatProviderSecretService = new ChatProviderSecretService(chatProviderRepository, credentialKeyProvider); + void chatProviderSecretService.migrateLegacySecrets().then((result) => { + if (result.status !== "ready") { + logger.warn("Connector secret migration is awaiting secure key readiness", { + logPurpose: "security", + pending: result.pending, + reason: result.reason, + }); + } + }).catch((error) => { + logger.warn("Connector secret migration readiness check failed", { + logPurpose: "security", + error: error instanceof Error ? error.message : String(error), + }); + }); const workerAttentionOutcomeService = new WorkerAttentionOutcomeService( projectAttentionService, connectionChatRepository, @@ -400,6 +417,7 @@ export function createCoreDependencies( projectRuntimeRepository, connectionChatRepository, chatProviderRepository, + chatProviderSecretService, workerEndpointRepository, projectWorkerAssignmentRepository, qaReviewRepository, diff --git a/src/app/dependency-factory/dashboard-factory.ts b/src/app/dependency-factory/dashboard-factory.ts index 26bd93a1fb..003f0d48ff 100644 --- a/src/app/dependency-factory/dashboard-factory.ts +++ b/src/app/dependency-factory/dashboard-factory.ts @@ -55,6 +55,7 @@ export interface DashboardDependencies { automationSloService: CoreDependencies["automationSloService"]; chatThreadRuntimeService: ChatThreadRuntimeService; chatProviderRepository: CoreDependencies["chatProviderRepository"]; + chatProviderSecretService: CoreDependencies["chatProviderSecretService"]; chatProviderIngressService: ChatProviderIngressService; chatProviderOutboundService: ChatProviderOutboundService; speechTranscriptionService: SpeechTranscriptionService; @@ -92,6 +93,7 @@ export function createDashboardDependencies( projectManagementRepository, connectionChatRepository, chatProviderRepository, + chatProviderSecretService, projectWorkerAssignmentRepository, projectAttentionService, agentPresetSyncService, @@ -156,6 +158,7 @@ export function createDashboardDependencies( taskRerunService: taskRerunServiceRef, settingsRepository: coreDeps.settingsRepository, chatProviderRepository: coreDeps.chatProviderRepository, + chatProviderSecretService: coreDeps.chatProviderSecretService, agentPresetSyncService: coreDeps.agentPresetSyncService, memoryService: coreDeps.memoryService, memoryPromotionService: coreDeps.memoryPromotionService, @@ -208,6 +211,7 @@ export function createDashboardDependencies( const chatProviderOutboundService = new ChatProviderOutboundService({ chatProviderRepository, + chatProviderSecretService, logger: logger.child({ component: "chat-provider-outbound-service" }), }); @@ -238,6 +242,7 @@ export function createDashboardDependencies( const chatProviderIngressService = new ChatProviderIngressService({ chatProviderRepository, + chatProviderSecretService, chatThreadRuntimeService, logger: logger.child({ component: "chat-provider-ingress-service" }), }); @@ -628,6 +633,7 @@ export function createDashboardDependencies( headlessReadinessService: coreDeps.headlessReadinessService, automationSloService: coreDeps.automationSloService, chatProviderRepository, + chatProviderSecretService, chatThreadRuntimeService, chatProviderIngressService, chatProviderOutboundService, diff --git a/src/app/lifecycle/dashboard-lifecycle-service.ts b/src/app/lifecycle/dashboard-lifecycle-service.ts index 29e336ad1d..626dba822f 100644 --- a/src/app/lifecycle/dashboard-lifecycle-service.ts +++ b/src/app/lifecycle/dashboard-lifecycle-service.ts @@ -103,6 +103,7 @@ import type { } from "../../services/local-mcp-cli-config-service.js"; import type { ProjectInitializationStateService } from "../../services/project-initialization-state-service.js"; import type { CredentialBroker } from "../../services/credentials/credential-broker.js"; +import type { ChatProviderSecretService } from "../../services/chat-provider-secret-service.js"; const updateCheckerService = new UpdateCheckerService(); @@ -121,6 +122,7 @@ export interface BootDashboardDeps { getDashboardNotifications?: () => ReturnType; connectionChatRepository: ConnectionChatRepository; chatProviderRepository: ChatProviderRepository; + chatProviderSecretService?: ChatProviderSecretService; projectWorkerAssignmentRepository: ProjectWorkerAssignmentRepository; projectWorkerAssignmentService: ProjectWorkerAssignmentService; projectAttentionRepository: ProjectAttentionRepository; @@ -510,6 +512,7 @@ export async function bootDashboard(deps: BootDashboardDeps): Promise | null; + verifiedAt: string | null; + secretVersion: number; createdAt: string; updatedAt: string; } @@ -185,6 +195,7 @@ export interface UpsertOutboundChatProviderDeliveryInput { status?: ChatProviderDeliveryStatus; attemptCount?: number; lastError?: string | null; + nextAttemptAt?: string | null; } export interface UpdateChatProviderDeliveryStateInput { @@ -195,6 +206,7 @@ export interface UpdateChatProviderDeliveryStateInput { conversationThreadId?: string | null; conversationMessageId?: string | null; payload?: Record | null; + nextAttemptAt?: string | null; } export interface ChatProviderMessageDeliveryRecord { @@ -211,10 +223,56 @@ export interface ChatProviderMessageDeliveryRecord { conversationThreadId: string | null; conversationMessageId: string | null; payload: Record | null; + nextAttemptAt: string | null; + leaseOwner: string | null; + leaseExpiresAt: string | null; createdAt: string; updatedAt: string; } +export interface ChatProviderIngressReplayReceiptRecord { + id: string; + providerConnectionId: string; + receiptKey: string; + expiresAt: string; + createdAt: string; +} + +export interface ChatProviderSessionStateRecord { + id: string; + providerConnectionId: string; + channelBindingId: string | null; + externalChannelId: string; + sessionKey: string; + state: Record; + version: number; + expiresAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateChatProviderSessionStateInput { + providerConnectionId: string; + channelBindingId?: string | null; + externalChannelId: string; + sessionKey: string; + state: Record; + expiresAt?: string | null; +} + +export interface ClaimChatProviderDeliveriesInput { + leaseOwner: string; + leaseDurationMs: number; + limit?: number; + now?: Date; +} + +export interface ReleaseChatProviderDeliveryInput { + status?: "pending" | "retryable_failure"; + nextAttemptAt?: string | null; + lastError?: string | null; +} + export { CHAT_PROVIDER_SETUP_SCHEMAS, getChatProviderSetupSchema, diff --git a/src/domain/chat-connectors/providers/discord.ts b/src/domain/chat-connectors/providers/discord.ts index f8d99b0771..60a317c21e 100644 --- a/src/domain/chat-connectors/providers/discord.ts +++ b/src/domain/chat-connectors/providers/discord.ts @@ -1,33 +1,14 @@ -import { createHash, createPublicKey, verify as verifySignature } from "node:crypto"; -import type { ChatProviderBridgeMode } from "../../../contracts/chat-provider-types.js"; -import { ChatConnectorOutboundExecutionError } from "../types.js"; -import type { - ChatConnectorOutboundContext, - ChatConnectorOutboundResult, - ChatConnectorProfile, - PartialNormalizedChatConnectorInbound, -} from "../types.js"; +import type { ChatConnectorProfile } from "../types.js"; import { - DEFAULT_CONNECTOR_TIMEOUT_MS, buildLegacyHttpOutboundRequest, isLegacyRetryableHttpStatus, parseLegacyOutboundResponse, - readArray, readRecord, readString, + resolveLegacyIdentity, verifyConnectorConfiguration, } from "../types.js"; -export const DISCORD_API_ORIGIN = "https://discord.com"; -export const DISCORD_API_BASE_URL = `${DISCORD_API_ORIGIN}/api/v10`; -export const DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"; -export const DISCORD_MESSAGE_CONTENT_INTENT = 1 << 15; -export const DISCORD_DEFAULT_INTENTS = (1 << 0) | (1 << 9) | (1 << 12) | DISCORD_MESSAGE_CONTENT_INTENT; - -const DEFAULT_INTERACTION_TOLERANCE_MS = 5 * 60 * 1_000; -const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); -const SNOWFLAKE_PATTERN = /^\d{1,20}$/; - const setupSchema = { kind: "discord", label: "Discord", @@ -46,462 +27,13 @@ const setupSchema = { { key: "webhookSecret", label: "Webhook signing secret", required: false }, ], }, - { - mode: "official_api", - label: "Discord official API", - integration: "official_api", - setupFields: [ - { key: "applicationId", label: "Application ID", type: "string", required: true }, - { key: "publicKey", label: "Interactions public key", type: "string", required: true }, - { - key: "intents", - label: "Gateway intents bitfield", - type: "string", - required: true, - defaultValue: String(DISCORD_DEFAULT_INTENTS), - }, - ], - secretFields: [ - { key: "botToken", label: "Bot token (write-only)", required: true }, - ], - }, ], } as const; -export type DiscordInteractionFailureCode = - | "missing_signature" - | "malformed_signature" - | "missing_timestamp" - | "malformed_timestamp" - | "stale_timestamp" - | "malformed_public_key" - | "signature_mismatch" - | "malformed_payload" - | "unsupported_interaction"; - -export type DiscordInteractionResult = - | { - ok: true; - kind: "ping"; - payload: Record; - response: { statusCode: 200; headers: { "content-type": "application/json" }; body: { type: 1 } }; - } - | { - ok: true; - kind: "message"; - payload: Record; - normalized: PartialNormalizedChatConnectorInbound; - } - | { - ok: false; - statusCode: 400 | 401; - code: DiscordInteractionFailureCode; - message: string; - }; - -export interface DiscordInteractionRequest { - headers: Headers | Readonly>; - rawBody: string | Uint8Array; - publicKey: string; - now?: Date; - timestampToleranceMs?: number; -} -export function verifyDiscordInteractionRequest(input: DiscordInteractionRequest): DiscordInteractionResult { - const signature = getHeader(input.headers, "x-signature-ed25519"); - if (!signature) { - return interactionFailure("missing_signature", "Missing Discord interaction signature.", 401); - } - if (!/^[a-f\d]{128}$/i.test(signature)) { - return interactionFailure("malformed_signature", "Malformed Discord interaction signature.", 401); - } - const timestamp = getHeader(input.headers, "x-signature-timestamp"); - if (!timestamp) { - return interactionFailure("missing_timestamp", "Missing Discord interaction timestamp.", 401); - } - if (!/^\d{1,16}$/.test(timestamp)) { - return interactionFailure("malformed_timestamp", "Malformed Discord interaction timestamp.", 401); - } - const timestampMs = Number(timestamp) * 1_000; - if (!Number.isSafeInteger(timestampMs)) { - return interactionFailure("malformed_timestamp", "Malformed Discord interaction timestamp.", 401); - } - const toleranceMs = input.timestampToleranceMs ?? DEFAULT_INTERACTION_TOLERANCE_MS; - if (Math.abs((input.now ?? new Date()).getTime() - timestampMs) > toleranceMs) { - return interactionFailure("stale_timestamp", "Discord interaction timestamp is outside the allowed window.", 401); - } - const publicKey = input.publicKey.trim(); - if (!/^[a-f\d]{64}$/i.test(publicKey)) { - return interactionFailure("malformed_public_key", "Malformed Discord interactions public key.", 401); - } - const rawBody = typeof input.rawBody === "string" ? Buffer.from(input.rawBody, "utf8") : Buffer.from(input.rawBody); - const signedBody = Buffer.concat([Buffer.from(timestamp, "utf8"), rawBody]); - try { - const key = createPublicKey({ - key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(publicKey, "hex")]), - format: "der", - type: "spki", - }); - if (!verifySignature(null, signedBody, key, Buffer.from(signature, "hex"))) { - return interactionFailure("signature_mismatch", "Invalid Discord interaction signature.", 401); - } - } catch { - return interactionFailure("signature_mismatch", "Invalid Discord interaction signature.", 401); - } - - let payload: Record; - try { - const parsed = JSON.parse(rawBody.toString("utf8")) as unknown; - const record = readRecord(parsed); - if (!record) { - throw new Error("not an object"); - } - payload = record; - } catch { - return interactionFailure("malformed_payload", "Malformed Discord interaction payload.", 400); - } - if (payload.type === 1) { - return { - ok: true, - kind: "ping", - payload, - response: { statusCode: 200, headers: { "content-type": "application/json" }, body: { type: 1 } }, - }; - } - const normalized = normalizeDiscordInteraction(payload); - if (!normalized) { - return interactionFailure("unsupported_interaction", "Unsupported Discord interaction payload.", 400); - } - return { ok: true, kind: "message", payload, normalized }; -} - -export type DiscordInboundEvent = - | { kind: "message"; normalized: PartialNormalizedChatConnectorInbound; payload: Record } - | { kind: "ignored"; reason: "self_message" | "unsupported_event" }; - -export function normalizeDiscordGatewayEvent( - payload: Record, - botUserId?: string | null, -): DiscordInboundEvent { - if (payload.t === undefined && readRecord(payload.data)) { - return { kind: "ignored", reason: "unsupported_event" }; - } - const event = payload.t === "MESSAGE_CREATE" ? readRecord(payload.d) : payload; - if (!event || (payload.t !== undefined && payload.t !== "MESSAGE_CREATE")) { - return { kind: "ignored", reason: "unsupported_event" }; - } - const author = readRecord(event.author); - if (botUserId && readString(author?.id) === botUserId) { - return { kind: "ignored", reason: "self_message" }; - } - const normalized = normalizeDiscordMessage(event); - return normalized.externalMessageId && normalized.externalChannelId && normalized.externalSenderId - ? { kind: "message", normalized, payload: event } - : { kind: "ignored", reason: "unsupported_event" }; -} - -function normalizeDiscordMessage(body: Record): PartialNormalizedChatConnectorInbound { - const channel = readRecord(body.channel); - const author = readRecord(body.author) ?? readRecord(body.member); - const user = readRecord(author?.user) ?? author; - const thread = readRecord(body.thread); - return { - externalChannelId: readString(body.channel_id, channel?.id), - externalChannelName: readString(channel?.name, body.channel_name, body.channel_id), - externalSenderId: readString(user?.id), - externalSenderName: readString(user?.global_name, user?.username, user?.name, user?.id), - textBody: readString(body.content, body.text), - externalMessageId: readString(body.id, body.message_id), - timestamp: body.timestamp, - externalThreadId: readString(body.thread_id, thread?.id, isThreadChannel(channel) ? channel?.id : undefined), - }; -} - -function normalizeDiscordInteraction(body: Record): PartialNormalizedChatConnectorInbound | null { - if (![2, 3, 5].includes(Number(body.type))) { - return null; - } - const channel = readRecord(body.channel); - const member = readRecord(body.member); - const user = readRecord(member?.user) ?? readRecord(body.user); - const data = readRecord(body.data); - const message = readRecord(body.message); - const resolved = readRecord(data?.resolved); - const resolvedMessages = readRecord(resolved?.messages); - const firstResolvedMessage = resolvedMessages ? readRecord(Object.values(resolvedMessages)[0]) : null; - const text = readString( - message?.content, - firstResolvedMessage?.content, - collectInteractionValues(data), - data?.custom_id, - data?.name, - ); - const channelId = readString(body.channel_id, channel?.id); - const senderId = readString(user?.id); - const messageId = readString(body.id); - if (!channelId || !senderId || !messageId || !text) { - return null; - } - return { - externalChannelId: channelId, - externalChannelName: readString(channel?.name, channelId), - externalSenderId: senderId, - externalSenderName: readString(user?.global_name, user?.username, member?.nick, senderId), - textBody: text, - externalMessageId: messageId, - timestamp: body.timestamp, - externalThreadId: readString(body.thread_id, isThreadChannel(channel) ? channelId : undefined), - }; -} - -function collectInteractionValues(data: Record | null): string | undefined { - const values: string[] = []; - const visit = (items: unknown): void => { - for (const item of readArray(items) ?? []) { - const option = readRecord(item); - if (!option) continue; - const value = readString(option.value); - if (value) values.push(value); - visit(option.options); - visit(option.components); - } - }; - visit(data?.options); - visit(data?.components); - return values.join(" ") || undefined; -} - -export type DiscordApiFailureCode = - | "invalid_auth" - | "missing_permissions" - | "rate_limited" - | "timeout" - | "cancelled" - | "ambiguous_network" - | "provider_unavailable" - | "invalid_response" - | "invalid_request"; - -export class DiscordApiError extends ChatConnectorOutboundExecutionError { - constructor( - readonly code: DiscordApiFailureCode, - message: string, - retryable: boolean, - statusCode?: number, - readonly retryAfterMs?: number, - ) { - super(message, retryable, statusCode); - this.name = "DiscordApiError"; - } -} - -export interface DiscordCredentialVerificationResult { - valid: boolean; - classification: "verified" | DiscordApiFailureCode; - botUserId?: string; - botUsername?: string; - issues: readonly string[]; -} - -interface DiscordApiClientDependencies { - fetch?: typeof fetch; - now?: () => number; - wait?: (delayMs: number, signal?: AbortSignal) => Promise; - timeoutMs?: number; - maxRateLimitWaitMs?: number; -} - -export interface SendDiscordReplyInput { - botToken: string; - channelId: string; - content: string; - deliveryId: string; - replyToMessageId?: string | null; - signal?: AbortSignal; -} - -export class DiscordOfficialApiClient { - private readonly fetchImpl: typeof fetch; - private readonly now: () => number; - private readonly wait: (delayMs: number, signal?: AbortSignal) => Promise; - private readonly timeoutMs: number; - private readonly maxRateLimitWaitMs: number; - private readonly routeAvailableAt = new Map(); - private globalAvailableAt = 0; - - constructor(deps: DiscordApiClientDependencies = {}) { - this.fetchImpl = deps.fetch ?? fetch; - this.now = deps.now ?? Date.now; - this.wait = deps.wait ?? abortableWait; - this.timeoutMs = deps.timeoutMs ?? DEFAULT_CONNECTOR_TIMEOUT_MS; - this.maxRateLimitWaitMs = deps.maxRateLimitWaitMs ?? 60_000; - } - - async verifyCredentials(botToken: string, signal?: AbortSignal): Promise { - try { - const response = await this.request("GET", "/users/@me", botToken, undefined, "current-user", signal, false); - const body = await readJsonRecord(response); - const id = readString(body?.id); - if (!id || !SNOWFLAKE_PATTERN.test(id)) { - throw new DiscordApiError("invalid_response", "Discord returned an invalid current-user response.", false); - } - return { - valid: true, - classification: "verified", - botUserId: id, - botUsername: readString(body?.global_name, body?.username), - issues: [], - }; - } catch (error) { - const normalized = normalizeDiscordApiError(error); - return { valid: false, classification: normalized.code, issues: [normalized.message] }; - } - } - - async sendReply(input: SendDiscordReplyInput): Promise { - requireSnowflake(input.channelId, "channel ID"); - if (input.replyToMessageId) requireSnowflake(input.replyToMessageId, "reply message ID"); - const nonce = stableDiscordNonce(input.deliveryId); - const body: Record = { - content: input.content, - allowed_mentions: { parse: [] }, - nonce, - enforce_nonce: true, - }; - if (input.replyToMessageId) { - body.message_reference = { message_id: input.replyToMessageId, fail_if_not_exists: false }; - } - const route = `channels:${input.channelId}:messages`; - const response = await this.request( - "POST", - `/channels/${encodeURIComponent(input.channelId)}/messages`, - input.botToken, - body, - route, - input.signal, - true, - ); - const parsed = await readJsonRecord(response); - const externalMessageId = readString(parsed?.id); - if (!externalMessageId || !SNOWFLAKE_PATTERN.test(externalMessageId)) { - throw new DiscordApiError("invalid_response", "Discord returned an invalid message response.", false); - } - return { externalMessageId, responseMetadata: { id: externalMessageId, nonce } }; - } - - private async request( - method: "GET" | "POST", - path: string, - botToken: string, - body: Record | undefined, - route: string, - signal: AbortSignal | undefined, - retryRateLimit: boolean, - ): Promise { - if (!botToken.trim()) { - throw new DiscordApiError("invalid_auth", "Discord bot authentication is not configured.", false, 401); - } - for (let attempt = 0; attempt < (retryRateLimit ? 2 : 1); attempt += 1) { - const delayMs = Math.max(this.globalAvailableAt, this.routeAvailableAt.get(route) ?? 0) - this.now(); - if (delayMs > this.maxRateLimitWaitMs) { - throw new DiscordApiError("rate_limited", "Discord rate limit requires deferred retry.", true, 429, delayMs); - } - if (delayMs > 0) await this.wait(delayMs, signal); - let response: Response; - try { - response = await this.fetchImpl(`${DISCORD_API_BASE_URL}${path}`, { - method, - headers: { - authorization: `Bot ${botToken}`, - ...(body ? { "content-type": "application/json" } : {}), - }, - body: body ? JSON.stringify(body) : undefined, - signal: combineSignals(signal, AbortSignal.timeout(this.timeoutMs)), - redirect: "error", - }); - } catch (error) { - if (signal?.aborted) throw new DiscordApiError("cancelled", "Discord request was cancelled.", false); - if (isAbortError(error)) throw new DiscordApiError("timeout", "Discord request timed out.", true); - throw new DiscordApiError("ambiguous_network", "Discord request outcome is unknown after a network failure.", true); - } - let retryAfterMs = this.captureRateLimits(route, response); - if (response.status === 429 && retryAfterMs === 0) { - retryAfterMs = await readRetryAfterBody(response); - if (retryAfterMs > 0) this.recordRateLimit(route, response, retryAfterMs); - } - if (response.ok) return response; - if (response.status === 429 && retryRateLimit && attempt === 0 && retryAfterMs > 0) { - if (retryAfterMs > this.maxRateLimitWaitMs) throw classifyDiscordHttpFailure(response.status, retryAfterMs); - await response.body?.cancel().catch(() => undefined); - continue; - } - throw classifyDiscordHttpFailure(response.status, retryAfterMs); - } - throw new DiscordApiError("rate_limited", "Discord rate limit remained active after one retry.", true, 429); - } - - private captureRateLimits(route: string, response: Response): number { - const retryAfterMs = parseSecondsHeader(response.headers.get("retry-after")) - ?? parseSecondsHeader(response.headers.get("x-ratelimit-reset-after")) - ?? 0; - if (response.headers.get("x-ratelimit-remaining") === "0" || response.status === 429) { - this.recordRateLimit(route, response, retryAfterMs); - } - return retryAfterMs; - } - - private recordRateLimit(route: string, response: Response, retryAfterMs: number): void { - const availableAt = this.now() + retryAfterMs; - if (response.headers.get("x-ratelimit-global") === "true") this.globalAvailableAt = availableAt; - else this.routeAvailableAt.set(route, availableAt); - } -} - -export function stableDiscordNonce(deliveryId: string): string { - return createHash("sha256").update(deliveryId).digest("hex").slice(0, 25); -} - -function buildOfficialOutboundRequest(context: ChatConnectorOutboundContext) { - const token = readString(context.connection.secrets?.botToken); - if (!token) throw new DiscordApiError("invalid_auth", "Discord bot authentication is not configured.", false); - requireSnowflake(context.payload.channelId, "channel ID"); - const nonce = stableDiscordNonce(context.delivery.id || context.payload.conversationMessageId); - return { - transport: "http" as const, - url: `${DISCORD_API_BASE_URL}/channels/${encodeURIComponent(context.payload.channelId)}/messages`, - label: "Discord API message endpoint", - headers: { - "content-type": "application/json", - "x-correlation-id": context.correlationId, - authorization: `Bot ${token}`, - }, - bearerSecretKeys: [], - body: { - content: context.payload.replyText, - allowed_mentions: { parse: [] }, - nonce, - enforce_nonce: true, - ...(context.payload.replyToExternalMessageId - ? { message_reference: { message_id: context.payload.replyToExternalMessageId, fail_if_not_exists: false } } - : {}), - }, - timeoutMs: DEFAULT_CONNECTOR_TIMEOUT_MS, - }; -} - -function parseOfficialOutboundResponse(text: string): ChatConnectorOutboundResult { - let body: Record | null = null; - try { body = readRecord(JSON.parse(text)); } catch { /* handled below */ } - const id = readString(body?.id); - if (!id || !SNOWFLAKE_PATTERN.test(id)) { - throw new DiscordApiError("invalid_response", "Discord returned an invalid message response.", false); - } - return { externalMessageId: id, responseMetadata: { id } }; -} - export const discordChatConnectorProfile: ChatConnectorProfile = { kind: "discord", setupSchema, - supportedTransportModes: ["webhook", "official_api"], + supportedTransportModes: ["webhook"], ingress: { authentication: { webhook: { @@ -512,68 +44,26 @@ export const discordChatConnectorProfile: ChatConnectorProfile = { signatureBases: ({ timestamp, rawBody }) => [`${timestamp}.${rawBody}`, `v0:${timestamp}:${rawBody}`, rawBody], }, }, - authenticateProviderRequest: ({ connection, headers, rawBody, now }) => { - if (connection.bridgeMode !== "official_api") return null; - const publicKey = readString(connection.setup.publicKey); - if (!publicKey) { - return { - authenticated: false, - code: "missing_public_key", - message: "Discord interactions public key is not configured.", - statusCode: 403, - }; - } - const result = verifyDiscordInteractionRequest({ headers, rawBody, publicKey, now }); - if (!result.ok) { - return { - authenticated: false, - code: result.code, - message: result.message, - statusCode: result.statusCode, - }; - } - return { - authenticated: true, - method: "discord_ed25519", - ...(result.kind === "ping" ? { immediateResponse: result.response } : {}), - }; - }, handshake: { type: "none" }, acknowledgement: { statusCode: 200, headers: { "content-type": "application/json" }, body: null }, normalize: (body) => { - const gateway = normalizeDiscordGatewayEvent(body); - if (gateway.kind === "message") return gateway.normalized; - return normalizeDiscordInteraction(body) ?? {}; - }, - }, - identity: { - resolve: (normalized, payload) => ({ - conversationId: readString(normalized.externalChannelId) ?? null, - threadId: readString(normalized.externalThreadId, payload.thread_id) ?? null, - }), - }, - outbound: { - createExecutor: (mode, runtime) => { - if (mode !== "official_api") return null; - const client = new DiscordOfficialApiClient(runtime); + const channel = readRecord(body.channel); + const author = readRecord(body.author) ?? readRecord(body.member); + const user = readRecord(author?.user) ?? author; return { - send: async (context) => { - const botToken = readString(context.connection.secrets?.botToken); - if (!botToken) { - throw new DiscordApiError("invalid_auth", "Discord bot authentication is not configured.", false, 401); - } - return client.sendReply({ - botToken, - channelId: context.payload.channelId, - content: context.payload.replyText, - deliveryId: context.delivery.id || context.payload.conversationMessageId, - replyToMessageId: context.payload.replyToExternalMessageId, - }); - }, + externalChannelId: readString(body.channel_id, channel?.id), + externalChannelName: readString(channel?.name, body.channel_name, body.channel_id), + externalSenderId: readString(user?.id), + externalSenderName: readString(user?.global_name, user?.username, user?.name, user?.id), + textBody: readString(body.content, body.text), + externalMessageId: readString(body.id, body.message_id), + timestamp: body.timestamp, }; }, + }, + identity: { resolve: resolveLegacyIdentity }, + outbound: { buildRequest: (context) => { - if (context.connection.bridgeMode === "official_api") return buildOfficialOutboundRequest(context); if (context.connection.bridgeMode !== "webhook") { throw new Error(`Unsupported bridge mode for discord: ${context.connection.bridgeMode}`); } @@ -584,127 +74,19 @@ export const discordChatConnectorProfile: ChatConnectorProfile = { label: "webhook bridge URL", }); }, - parseResponse: (body) => { - try { return parseOfficialOutboundResponse(body); } catch { return parseLegacyOutboundResponse(body); } - }, + parseResponse: parseLegacyOutboundResponse, isRetryableStatus: isLegacyRetryableHttpStatus, }, verification: { - strategy: "configuration_and_live", - capabilities: ["setup", "authentication", "handshake", "outbound"], - verifyConfiguration: (mode: ChatProviderBridgeMode, setup, secrets) => { - const result = verifyConnectorConfiguration(setupSchema, mode, setup, secrets); - if (!result.valid || mode !== "official_api") return result; - const issues = [...result.issues]; - if (!/^[a-f\d]{64}$/i.test(readString(setup.publicKey) ?? "")) issues.push("Invalid Discord interactions public key."); - const intents = Number(readString(setup.intents)); - if (!Number.isSafeInteger(intents) || intents < 0) issues.push("Invalid Discord Gateway intents bitfield."); - if (Number.isSafeInteger(intents) && (intents & DISCORD_MESSAGE_CONTENT_INTENT) === 0) { - issues.push("Discord MESSAGE_CONTENT intent is required to receive ordinary message text."); - } - return { valid: issues.length === 0, issues }; - }, - }, - session: { - required: true, - scope: "connection", - requirements: [ - "Official API delivery owns a resumable Gateway v10 session.", - "MESSAGE_CONTENT is a privileged intent and must be enabled in the Discord Developer Portal.", - ], + strategy: "configuration", + capabilities: ["setup", "authentication", "outbound"], + verifyConfiguration: (mode, setup, secrets) => verifyConnectorConfiguration(setupSchema, mode, setup, secrets), }, + session: { required: true, scope: "connection", requirements: ["A bot or gateway session owns provider event delivery."] }, officialDocumentation: [ - { label: "Discord Gateway", url: "https://docs.discord.com/developers/events/gateway" }, - { label: "Discord Gateway events", url: "https://docs.discord.com/developers/events/gateway-events" }, - { label: "Discord interactions", url: "https://docs.discord.com/developers/interactions/overview" }, + { label: "Discord interactions", url: "https://docs.discord.com/developers/interactions/receiving-and-responding" }, { label: "Discord messages", url: "https://docs.discord.com/developers/resources/message" }, - { label: "Discord rate limits", url: "https://docs.discord.com/developers/topics/rate-limits" }, ], - liveTest: { available: true, modes: ["official_api"], reason: "Uses Discord's read-only current-user endpoint." }, - lifecycle: { status: "preview", profileVersion: 2, introducedIn: "discord-official-api" }, + liveTest: { available: false, modes: [], reason: "Baseline bridge profiles do not invoke provider endpoints." }, + lifecycle: { status: "baseline", profileVersion: 1, introducedIn: "typed-registry" }, }; - -function interactionFailure( - code: DiscordInteractionFailureCode, - message: string, - statusCode: 400 | 401, -): DiscordInteractionResult { - return { ok: false, code, message, statusCode }; -} - -function getHeader( - headers: Headers | Readonly>, - name: string, -): string | undefined { - if (headers instanceof Headers) return headers.get(name)?.trim() || undefined; - const value = Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1]; - const first = Array.isArray(value) ? value[0] : value; - return first?.trim() || undefined; -} - -function isThreadChannel(channel: Record | null): boolean { - return [10, 11, 12].includes(Number(channel?.type)); -} - -function requireSnowflake(value: string, label: string): void { - if (!SNOWFLAKE_PATTERN.test(value)) { - throw new DiscordApiError("invalid_request", `Invalid Discord ${label}.`, false); - } -} - -function classifyDiscordHttpFailure(status: number, retryAfterMs: number): DiscordApiError { - if (status === 401) return new DiscordApiError("invalid_auth", "Discord rejected bot authentication.", false, status); - if (status === 403) return new DiscordApiError("missing_permissions", "Discord bot permissions are insufficient.", false, status); - if (status === 429) return new DiscordApiError("rate_limited", "Discord rate limit is active.", true, status, retryAfterMs); - if (status >= 500) return new DiscordApiError("provider_unavailable", "Discord API is temporarily unavailable.", true, status); - return new DiscordApiError("invalid_request", `Discord API rejected the request with HTTP ${status}.`, false, status); -} - -function normalizeDiscordApiError(error: unknown): DiscordApiError { - return error instanceof DiscordApiError - ? error - : new DiscordApiError("ambiguous_network", "Discord request failed with an unknown outcome.", true); -} - -async function readJsonRecord(response: Response): Promise | null> { - try { return readRecord(await response.json()); } catch { return null; } -} - -async function readRetryAfterBody(response: Response): Promise { - try { - const body = readRecord(await response.clone().json()); - const seconds = Number(body?.retry_after); - return Number.isFinite(seconds) && seconds >= 0 ? Math.ceil(seconds * 1_000) : 0; - } catch { - return 0; - } -} - -function parseSecondsHeader(value: string | null): number | null { - if (!value) return null; - const seconds = Number(value); - return Number.isFinite(seconds) && seconds >= 0 ? Math.ceil(seconds * 1_000) : null; -} - -function isAbortError(error: unknown): boolean { - return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError"); -} - -function combineSignals(left: AbortSignal | undefined, right: AbortSignal): AbortSignal { - return left ? AbortSignal.any([left, right]) : right; -} - -function abortableWait(delayMs: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new DiscordApiError("cancelled", "Discord request was cancelled.", false)); - return; - } - const timer = setTimeout(resolve, delayMs); - timer.unref?.(); - signal?.addEventListener("abort", () => { - clearTimeout(timer); - reject(new DiscordApiError("cancelled", "Discord request was cancelled.", false)); - }, { once: true }); - }); -} diff --git a/src/domain/chat-connectors/providers/microsoft-teams.ts b/src/domain/chat-connectors/providers/microsoft-teams.ts index a153106114..44663a54c5 100644 --- a/src/domain/chat-connectors/providers/microsoft-teams.ts +++ b/src/domain/chat-connectors/providers/microsoft-teams.ts @@ -1,71 +1,14 @@ -import type { ChatConnectorProfile, PartialNormalizedChatConnectorInbound } from "../types.js"; +import type { ChatConnectorProfile } from "../types.js"; import { - DEFAULT_CONNECTOR_TIMEOUT_MS, buildLegacyHttpOutboundRequest, isLegacyRetryableHttpStatus, parseLegacyOutboundResponse, - readArray, readRecord, readString, + resolveLegacyIdentity, verifyConnectorConfiguration, } from "../types.js"; -export type MicrosoftBotApplicationType = "MultiTenant" | "SingleTenant"; - -export interface MicrosoftTeamsChannelAccount { - id: string; - name?: string; - aadObjectId?: string; -} - -export interface MicrosoftTeamsConversationAccount { - id: string; - name?: string; - conversationType?: string; - isGroup?: boolean; -} - -export interface MicrosoftTeamsConversationReference { - activityId: string; - serviceUrl: string; - serviceUrlValidated: true; - channelId: string; - locale?: string; - tenantId?: string; - teamId?: string; - teamsChannelId?: string; - conversation: MicrosoftTeamsConversationAccount; - bot: MicrosoftTeamsChannelAccount; - user: MicrosoftTeamsChannelAccount; -} - -export interface NormalizedMicrosoftTeamsActivity extends PartialNormalizedChatConnectorInbound { - activityType: "message"; - channelId?: string; - locale?: string; - tenantId?: string; - teamId?: string; - teamsChannelId?: string; - replyToId?: string; - conversation?: MicrosoftTeamsConversationAccount; -} - -export class UnsupportedMicrosoftTeamsActivityError extends Error { - readonly code = "unsupported_activity_type"; - - constructor(readonly activityType: string) { - super(`Unsupported Microsoft Teams activity type: ${activityType || "missing"}.`); - this.name = "UnsupportedMicrosoftTeamsActivityError"; - } -} - -const DOCUMENTED_MICROSOFT_BOT_SERVICE_HOSTS = new Set([ - "smba.trafficmanager.net", - "smba.infra.gcc.teams.microsoft.com", - "smba.infra.gov.teams.microsoft.us", - "smba.infra.dod.teams.microsoft.us", -]); - const setupSchema = { kind: "microsoft-teams", label: "Microsoft Teams", @@ -94,136 +37,13 @@ const setupSchema = { { key: "webhookSecret", label: "Webhook signing secret", required: false }, ], }, - { - mode: "official_api", - label: "Microsoft Bot Connector API", - integration: "official_api", - setupFields: [ - { key: "microsoftAppId", label: "Microsoft app ID", type: "string", required: true }, - { - key: "applicationType", - label: "Application type", - type: "select", - required: true, - defaultValue: "MultiTenant", - options: ["MultiTenant", "SingleTenant"], - }, - { key: "tenantId", label: "Microsoft tenant ID", type: "string", required: false }, - ], - secretFields: [{ key: "clientSecret", label: "Client secret", required: true }], - }, ], } as const; -export function normalizeMicrosoftTeamsActivity( - body: Record, - options: { requireType?: boolean } = {}, -): NormalizedMicrosoftTeamsActivity { - const activityType = readString(body.type) ?? ""; - if ((options.requireType && activityType !== "message") || (activityType && activityType !== "message")) { - throw new UnsupportedMicrosoftTeamsActivityError(activityType); - } - - const conversation = readRecord(body.conversation); - const sender = readRecord(body.from); - const channelData = readRecord(body.channelData); - const tenant = readRecord(channelData?.tenant); - const team = readRecord(channelData?.team); - const teamsChannel = readRecord(channelData?.channel); - return { - activityType: "message", - externalChannelId: readString(conversation?.id, teamsChannel?.id, body.channelId), - externalChannelName: readString(teamsChannel?.name, conversation?.name, conversation?.id), - externalSenderId: readString(sender?.aadObjectId, sender?.id), - externalSenderName: readString(sender?.name, sender?.id), - textBody: removeBotMention(readString(body.text, body.body, body.content), body), - externalMessageId: readString(body.id, body.replyToId), - timestamp: body.timestamp ?? body.localTimestamp, - channelId: readString(body.channelId), - locale: readString(body.locale), - tenantId: readString(tenant?.id), - teamId: readString(team?.id), - teamsChannelId: readString(teamsChannel?.id), - replyToId: readString(body.replyToId), - conversation: conversation - ? { - id: readString(conversation.id) ?? "", - name: readString(conversation.name), - conversationType: readString(conversation.conversationType), - isGroup: typeof conversation.isGroup === "boolean" ? conversation.isGroup : undefined, - } - : undefined, - }; -} - -export function verifyMicrosoftTeamsConfiguration( - mode: Parameters[0], - setup: Record, - secrets: Record | null, -): ReturnType { - const baseline = verifyConnectorConfiguration(setupSchema, mode, setup, secrets); - if (mode !== "official_api") { - return baseline; - } - - const issues = [...baseline.issues]; - const applicationType = readString(setup.applicationType); - if (applicationType !== "MultiTenant" && applicationType !== "SingleTenant") { - issues.push("Invalid application type: expected MultiTenant or SingleTenant"); - } - if (applicationType === "SingleTenant" && !readString(setup.tenantId)) { - issues.push("Missing required setup field for SingleTenant application: tenantId"); - } - return { valid: issues.length === 0, issues }; -} - -export function buildMicrosoftTeamsActivityReplyRequest( - reference: MicrosoftTeamsConversationReference, - replyText: string, - correlationId: string, -): ReturnType { - if (reference.serviceUrlValidated !== true) { - throw new Error("Microsoft Teams conversation reference does not contain a validated service URL."); - } - const baseUrl = new URL(reference.serviceUrl); - if (!isDocumentedMicrosoftBotServiceUrl(baseUrl)) { - throw new Error("Microsoft Teams conversation reference contains an invalid service URL."); - } - const basePath = baseUrl.pathname.replace(/\/+$/, ""); - baseUrl.pathname = `${basePath}/v3/conversations/${encodeURIComponent(reference.conversation.id)}/activities/${encodeURIComponent(reference.activityId)}`; - baseUrl.search = ""; - baseUrl.hash = ""; - return { - transport: "http" as const, - url: baseUrl.toString(), - label: "validated Microsoft Bot Connector service URL", - headers: { - "content-type": "application/json", - "x-correlation-id": correlationId, - }, - bearerSecretKeys: [] as const, - body: { - type: "message", - from: reference.bot, - recipient: reference.user, - conversation: reference.conversation, - locale: reference.locale, - replyToId: reference.activityId, - text: replyText, - channelData: { - tenant: reference.tenantId ? { id: reference.tenantId } : undefined, - team: reference.teamId ? { id: reference.teamId } : undefined, - channel: reference.teamsChannelId ? { id: reference.teamsChannelId } : undefined, - }, - }, - timeoutMs: DEFAULT_CONNECTOR_TIMEOUT_MS, - }; -} - export const microsoftTeamsChatConnectorProfile: ChatConnectorProfile = { kind: "microsoft-teams", setupSchema, - supportedTransportModes: ["managed_bridge", "webhook", "official_api"], + supportedTransportModes: ["managed_bridge", "webhook"], ingress: { authentication: { managed_bridge: { @@ -239,23 +59,24 @@ export const microsoftTeamsChatConnectorProfile: ChatConnectorProfile = { timestampHeaders: ["x-code-ux-timestamp", "x-provider-timestamp", "x-slack-request-timestamp"], signatureBases: ({ timestamp, rawBody }) => [`${timestamp}.${rawBody}`, `v0:${timestamp}:${rawBody}`, rawBody], }, - official_api: { - type: "bearer", - secretKeys: [], - tokenHeaders: ["authorization"], - timestampHeaders: [], - }, }, handshake: { type: "none" }, acknowledgement: { statusCode: 200, headers: { "content-type": "application/json" }, body: null }, - normalize: (payload, mode) => normalizeMicrosoftTeamsActivity(payload, { requireType: mode === "official_api" }), - }, - identity: { - resolve: (normalized, payload) => ({ - conversationId: readString(normalized.externalChannelId) ?? null, - threadId: readString(payload.replyToId, payload.id) ?? null, - }), + normalize: (body) => { + const conversation = readRecord(body.conversation); + const sender = readRecord(body.from); + return { + externalChannelId: readString(conversation?.id, body.channelId), + externalChannelName: readString(conversation?.name, conversation?.id), + externalSenderId: readString(sender?.id), + externalSenderName: readString(sender?.name, sender?.id), + textBody: readString(body.text, body.body, body.content), + externalMessageId: readString(body.id, body.replyToId), + timestamp: body.timestamp ?? body.localTimestamp, + }; + }, }, + identity: { resolve: resolveLegacyIdentity }, outbound: { buildRequest: (context) => { const mode = context.connection.bridgeMode; @@ -275,13 +96,6 @@ export const microsoftTeamsChatConnectorProfile: ChatConnectorProfile = { label: "webhook bridge URL", }); } - if (mode === "official_api") { - const reference = findConversationReference(context.payload.metadata); - if (!reference) { - throw new Error("Microsoft Teams official_api delivery requires a persisted validated conversation reference."); - } - return buildMicrosoftTeamsActivityReplyRequest(reference, context.payload.replyText, context.correlationId); - } throw new Error(`Unsupported bridge mode for microsoft-teams: ${mode}`); }, parseResponse: parseLegacyOutboundResponse, @@ -290,136 +104,10 @@ export const microsoftTeamsChatConnectorProfile: ChatConnectorProfile = { verification: { strategy: "configuration", capabilities: ["setup", "authentication", "outbound"], - verifyConfiguration: verifyMicrosoftTeamsConfiguration, - }, - session: { - required: true, - scope: "conversation", - requirements: ["Persist a conversation reference only after Bot Connector JWT and service URL validation."], + verifyConfiguration: (mode, setup, secrets) => verifyConnectorConfiguration(setupSchema, mode, setup, secrets), }, - officialDocumentation: [ - { - label: "Bot Connector authentication", - url: "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0", - }, - { - label: "Bot Connector send and receive messages", - url: "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-send-and-receive-messages?view=azure-bot-service-4.0", - }, - { - label: "Activity protocol", - url: "https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/activity-protocol", - }, - ], - liveTest: { - available: false, - modes: [], - reason: "Microsoft provides Bot Framework Emulator and Agents tooling for local tests, not a public unauthenticated sandbox endpoint.", - }, - lifecycle: { status: "stable", profileVersion: 2, introducedIn: "typed-registry" }, + session: { required: false, scope: "connection", requirements: [] }, + officialDocumentation: [{ label: "Teams conversational bots", url: "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" }], + liveTest: { available: false, modes: [], reason: "Baseline bridge profiles do not invoke provider endpoints." }, + lifecycle: { status: "baseline", profileVersion: 1, introducedIn: "typed-registry" }, }; - -function removeBotMention(text: string | undefined, body: Record): string | undefined { - if (!text) { - return undefined; - } - const recipient = readRecord(body.recipient); - const botId = readString(recipient?.id); - const botName = readString(recipient?.name); - let normalized = text; - for (const value of readArray(body.entities) ?? []) { - const entity = readRecord(value); - if (readString(entity?.type)?.toLowerCase() !== "mention") { - continue; - } - const mentioned = readRecord(entity?.mentioned); - const mentionedId = readString(mentioned?.id); - const mentionedName = readString(mentioned?.name); - if (botId && mentionedId !== botId) { - continue; - } - if (!botId && botName && mentionedName !== botName) { - continue; - } - const mentionText = readString(entity?.text); - if (mentionText) { - normalized = normalized.replace(new RegExp(escapeRegExp(mentionText), "gi"), " "); - } - if (mentionedName) { - normalized = normalized.replace(new RegExp(`\\s*${escapeRegExp(mentionedName)}\\s*`, "gi"), " "); - } - } - return normalized.replace(/ /gi, " ").replace(/\s+/g, " ").trim() || undefined; -} - -function findConversationReference(metadata: Record): MicrosoftTeamsConversationReference | null { - const direct = readRecord(metadata.microsoftTeamsConversationReference); - const inboundPayload = readRecord(metadata.inboundPayload); - const rawMetadata = readRecord(inboundPayload?.rawMetadata); - const nested = readRecord(rawMetadata?.microsoftTeamsConversationReference); - return parseConversationReference(direct ?? nested); -} - -function parseConversationReference(value: Record | null): MicrosoftTeamsConversationReference | null { - if (!value || value.serviceUrlValidated !== true) { - return null; - } - const conversation = readRecord(value.conversation); - const bot = readRecord(value.bot); - const user = readRecord(value.user); - const activityId = readString(value.activityId); - const serviceUrl = readString(value.serviceUrl); - const channelId = readString(value.channelId); - const conversationId = readString(conversation?.id); - const botId = readString(bot?.id); - const userId = readString(user?.id); - if (!activityId || !serviceUrl || !channelId || !conversationId || !botId || !userId) { - return null; - } - return { - activityId, - serviceUrl, - serviceUrlValidated: true, - channelId, - locale: readString(value.locale), - tenantId: readString(value.tenantId), - teamId: readString(value.teamId), - teamsChannelId: readString(value.teamsChannelId), - conversation: { - id: conversationId, - name: readString(conversation?.name), - conversationType: readString(conversation?.conversationType), - isGroup: typeof conversation?.isGroup === "boolean" ? conversation.isGroup : undefined, - }, - bot: { - id: botId, - name: readString(bot?.name), - aadObjectId: readString(bot?.aadObjectId), - }, - user: { - id: userId, - name: readString(user?.name), - aadObjectId: readString(user?.aadObjectId), - }, - }; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -export function isDocumentedMicrosoftBotServiceUrl(value: string | URL): boolean { - try { - const url = value instanceof URL ? value : new URL(value); - const hostname = url.hostname.toLowerCase(); - return url.protocol === "https:" - && !url.username - && !url.password - && !url.port - && !url.search - && !url.hash - && DOCUMENTED_MICROSOFT_BOT_SERVICE_HOSTS.has(hostname); - } catch { - return false; - } -} diff --git a/src/domain/chat-connectors/types.ts b/src/domain/chat-connectors/types.ts index 9a627c3382..ff1acfce3a 100644 --- a/src/domain/chat-connectors/types.ts +++ b/src/domain/chat-connectors/types.ts @@ -18,7 +18,6 @@ export interface PartialNormalizedChatConnectorInbound { externalMessageId?: string; conversationThreadId?: string; timestamp?: unknown; - externalThreadId?: string; } export interface ChatConnectorIgnoreResult { @@ -31,32 +30,6 @@ export interface ChatConnectorAuthenticationInput { rawBody: string; } -export interface ChatConnectorProviderIngressRequest { - connection: ChatProviderConnectionInternalRecord; - headers: Readonly>; - rawBody: string | Uint8Array; - now: Date; -} - -export interface ChatConnectorImmediateResponse { - statusCode: number; - headers: Readonly>; - body: unknown; -} - -export type ChatConnectorProviderIngressResult = - | { - authenticated: true; - method: string; - immediateResponse?: ChatConnectorImmediateResponse; - } - | { - authenticated: false; - code: string; - message: string; - statusCode: number; - }; - export interface ChatConnectorBearerAuthentication { type: "bearer"; secretKeys: readonly string[]; @@ -213,16 +186,6 @@ export interface ChatConnectorOutboundResponseContext { headers: Readonly>; } -export interface ChatConnectorOutboundExecutor { - send(context: ChatConnectorOutboundContext): Promise; -} - -export interface ChatConnectorOutboundRuntime { - fetch: typeof fetch; - now?: () => number; - wait?: (delayMs: number, signal?: AbortSignal) => Promise; -} - export class ChatConnectorOutboundResponseError extends Error { constructor( message: string, @@ -235,17 +198,6 @@ export class ChatConnectorOutboundResponseError extends Error { } } -/** - * Compatibility base for provider implementations that predate the - * response-context error contract. - */ -export class ChatConnectorOutboundExecutionError extends ChatConnectorOutboundResponseError { - constructor(message: string, retryable: boolean, statusCode?: number) { - super(message, retryable, statusCode); - this.name = "ChatConnectorOutboundExecutionError"; - } -} - export interface ChatConnectorVerificationResult { valid: boolean; issues: readonly string[]; @@ -257,9 +209,6 @@ export interface ChatConnectorProfile { supportedTransportModes: readonly ChatProviderBridgeMode[]; ingress: { authentication: Readonly>>; - authenticateProviderRequest?( - request: ChatConnectorProviderIngressRequest, - ): ChatConnectorProviderIngressResult | null; handshake: ChatConnectorHandshake; acknowledgement: ChatConnectorAcknowledgement; classify?(payload: Record): "message" | "ignored"; @@ -274,10 +223,6 @@ export interface ChatConnectorProfile { ): ChatConnectorExternalIdentity; }; outbound: { - createExecutor?( - mode: ChatProviderBridgeMode, - runtime: ChatConnectorOutboundRuntime, - ): ChatConnectorOutboundExecutor | null; buildRequest(context: ChatConnectorOutboundContext): ChatConnectorOutboundRequest; parseResponse( responseBody: string, diff --git a/src/infrastructure/security/encrypted-sqlite-secret-store.ts b/src/infrastructure/security/encrypted-sqlite-secret-store.ts index 204e94367f..8c8632809e 100644 --- a/src/infrastructure/security/encrypted-sqlite-secret-store.ts +++ b/src/infrastructure/security/encrypted-sqlite-secret-store.ts @@ -1,10 +1,13 @@ import type { KeyProvider } from "../../services/credentials/key-provider.js"; import type { SecretContext, SecretStore, StoredSecretEnvelope } from "../../services/credentials/secret-store.js"; import { decryptEnvelope, encryptEnvelope } from "../../services/credentials/encryption-utils.js"; -import type { AutomationCredentialRepository } from "../../repositories/automation-credential-repository.js"; + +export interface SecretEnvelopeRepository { + getEnvelope(credentialId: string): StoredSecretEnvelope | null; +} export class EncryptedSqliteSecretStore implements SecretStore { - constructor(private readonly repository: AutomationCredentialRepository, private readonly keyProvider: KeyProvider) {} + constructor(private readonly repository: SecretEnvelopeRepository, private readonly keyProvider: KeyProvider) {} async seal(context: SecretContext, plaintext: Buffer): Promise { const health = await this.keyProvider.health(); if (!health.available || !health.secure) throw new Error(health.reason ?? "Secure key provider is unavailable."); diff --git a/src/mcp/management-tool-handler.ts b/src/mcp/management-tool-handler.ts index 4657ff32c9..8860f112f6 100644 --- a/src/mcp/management-tool-handler.ts +++ b/src/mcp/management-tool-handler.ts @@ -76,6 +76,7 @@ import { NodeFlowActions, formatRunSummary } from "./management/node-flow-action import { MemoryActions } from "./management/memory-actions.js"; import { SkillActions } from "./management/skill-actions.js"; import { ChatProviderActions } from "./management/chat-provider-actions.js"; +import type { ChatProviderSecretService } from "../services/chat-provider-secret-service.js"; import { buildMcpApprovalFingerprint, formatManagementErrorEnvelope } from "./management/payload-parsers.js"; import { resolveLateBoundDependency, type LateBoundOrValue } from "../shared/late-bound-dependency.js"; @@ -90,6 +91,7 @@ export interface ManagementToolHandlerDeps { taskRerunService: LateBoundOrValue; settingsRepository: SettingsRepository; chatProviderRepository: ChatProviderRepository; + chatProviderSecretService?: ChatProviderSecretService; agentPresetSyncService: AgentPresetSyncService; memoryService: MemoryService; memoryPromotionService: MemoryPromotionService; @@ -137,7 +139,7 @@ export class ManagementToolHandler { deps.customDashboardRepository, deps.customDashboardValidationService, ); - this.chatProviderActions = new ChatProviderActions(deps.chatProviderRepository); + this.chatProviderActions = new ChatProviderActions(deps.chatProviderRepository, deps.chatProviderSecretService); } private getSprintActions(): SprintActions { diff --git a/src/mcp/management/chat-provider-actions.ts b/src/mcp/management/chat-provider-actions.ts index 94cbb525c8..2b5b68bc98 100644 --- a/src/mcp/management/chat-provider-actions.ts +++ b/src/mcp/management/chat-provider-actions.ts @@ -13,6 +13,7 @@ import type { } from "../../contracts/chat-provider-types.js"; import type { ManageCodeUxArgs, ManagementResponseEnvelope } from "../../contracts/internal-management-types.js"; import type { ChatProviderRepository } from "../../repositories/chat-provider-repository.js"; +import type { ChatProviderSecretService } from "../../services/chat-provider-secret-service.js"; import { buildMcpApprovalFingerprint, managementValidationError, @@ -197,7 +198,10 @@ function success(action: string, data: Record): ManagementRespo export class ChatProviderActions { private readonly pendingSecretApprovals = new Map(); - constructor(private readonly chatProviderRepository: ChatProviderRepository) {} + constructor( + private readonly chatProviderRepository: ChatProviderRepository, + private readonly chatProviderSecretService?: ChatProviderSecretService, + ) {} async handleChatProviderAction(args: ManageCodeUxArgs): Promise { const payload = args.payload || {}; @@ -299,10 +303,10 @@ export class ChatProviderActions { return success(action, { connection: withConnectionIngress(connection, normalizeBaseUrl(payload)) }); } - private createConnection(action: string, payload: Record): ManagementResponseEnvelope { + private async createConnection(action: string, payload: Record): Promise { const setup = parseOptionalObject(payload, "setup"); const secrets = parseOptionalNullableObject(payload, "secrets"); - const connection = this.chatProviderRepository.createConnection({ + const input = { providerKind: parseRequiredProviderKind(payload), displayName: parseRequiredString(payload, "displayName"), bridgeMode: parseOptionalEnumStrict(payload, "bridgeMode", BRIDGE_MODES), @@ -310,11 +314,14 @@ export class ChatProviderActions { enabled: parseOptionalBoolean(payload, "enabled"), ...(setup !== undefined ? { setup } : {}), ...(secrets !== undefined ? { secrets } : {}), - }); + }; + const connection = this.chatProviderSecretService + ? await this.chatProviderSecretService.createConnection(input) + : this.chatProviderRepository.createConnection(input); return success(action, { connection: withConnectionIngress(connection, normalizeBaseUrl(payload)) }); } - private updateConnection(args: ManageCodeUxArgs, payload: Record): ManagementResponseEnvelope { + private async updateConnection(args: ManageCodeUxArgs, payload: Record): Promise { const approval = this.requireSecretReplacementApproval(args, payload); if (approval) { return approval; @@ -322,14 +329,18 @@ export class ChatProviderActions { const setup = parseOptionalObject(payload, "setup"); const secrets = parseOptionalNullableObject(payload, "secrets"); - const connection = this.chatProviderRepository.updateConnection(parseConnectionId(payload), { + const connectionId = parseConnectionId(payload); + const input = { displayName: parseOptionalString(payload, "displayName"), bridgeMode: parseOptionalEnumStrict(payload, "bridgeMode", BRIDGE_MODES), status: parseOptionalEnumStrict(payload, "status", CONNECTION_STATUSES), enabled: parseOptionalBoolean(payload, "enabled"), ...(setup !== undefined ? { setup } : {}), ...(secrets !== undefined ? { secrets } : {}), - }); + }; + const connection = this.chatProviderSecretService + ? await this.chatProviderSecretService.updateConnection(connectionId, input) + : this.chatProviderRepository.updateConnection(connectionId, input); return success(args.action, { connection: withConnectionIngress(connection, normalizeBaseUrl(payload)) }); } diff --git a/src/repositories/chat-provider-repository.ts b/src/repositories/chat-provider-repository.ts index 446063c9fc..5ea459e145 100644 --- a/src/repositories/chat-provider-repository.ts +++ b/src/repositories/chat-provider-repository.ts @@ -5,6 +5,11 @@ import type { ChatProviderConnectionInternalRecord, ChatProviderConnectionRecord, ChatProviderConnectionStatus, + ChatProviderVerificationStatus, + ChatProviderIngressReplayReceiptRecord, + ChatProviderSessionStateRecord, + ClaimChatProviderDeliveriesInput, + CreateChatProviderSessionStateInput, ChatProviderDeliveryDirection, ChatProviderDeliveryStatus, ChatProviderKind, @@ -15,6 +20,7 @@ import type { CreateChatProviderConnectionInput, RecordInboundChatProviderMessageInput, RedactedCredentialField, + ReleaseChatProviderDeliveryInput, UpdateChatProviderChannelBindingInput, UpdateChatProviderConnectionInput, UpdateChatProviderDeliveryStateInput, @@ -27,6 +33,8 @@ import { import { AppDbStorage } from "./app-db-storage.js"; import type { DatabaseAdapter } from "./db/database-adapter.js"; import { EntityNotFoundError, requireRecord, toBoolean, toNumber, ValidationError } from "./repository-utils.js"; +import type { StoredSecretEnvelope } from "../services/credentials/secret-store.js"; +import { redactMetadata } from "../shared/security/redaction.js"; const CHAT_PROVIDER_KINDS = new Set([ "whatsapp", @@ -55,6 +63,13 @@ const DELIVERY_STATUSES = new Set([ "cancelled", ]); +const VERIFICATION_STATUSES = new Set([ + "unverified", + "pending", + "verified", + "failed", +]); + interface ChatProviderConnectionRow { id: string; provider_kind: string; @@ -64,6 +79,11 @@ interface ChatProviderConnectionRow { enabled: number | string; setup_json: string | null; secret_json: string | null; + secret_keys_json?: string | null; + verification_status: string; + verification_details_json: string | null; + verified_at: string | null; + secret_version: number | string; created_at: string; updated_at: string; } @@ -100,10 +120,63 @@ interface ChatProviderMessageDeliveryRow { conversation_thread_id: string | null; conversation_message_id: string | null; payload_json: string | null; + next_attempt_at: string | null; + lease_owner: string | null; + lease_expires_at: string | null; + created_at: string; + updated_at: string; +} + +interface ChatProviderSecretRow { + provider_connection_id: string; + ciphertext: Buffer; + nonce: Buffer; + auth_tag: Buffer; + wrapped_data_key: Buffer; + wrap_nonce: Buffer; + wrap_auth_tag: Buffer; + key_id: string; + key_version: number | string; + secret_keys_json: string; +} + +interface ChatProviderReplayReceiptRow { + id: string; + provider_connection_id: string; + receipt_key: string; + expires_at: string; + created_at: string; +} + +interface ChatProviderSessionRow { + id: string; + provider_connection_id: string; + channel_binding_id: string | null; + external_channel_id: string; + session_key: string; + state_json: string; + version: number | string; + expires_at: string | null; created_at: string; updated_at: string; } +interface PreparedChatProviderConnectionUpdate { + displayName: string; + bridgeMode: ChatProviderBridgeMode; + status: ChatProviderConnectionStatus; + enabled: boolean; + setup: ChatProviderSetupConfig; + transportChanged: boolean; +} + +export class ChatProviderConcurrentModificationError extends Error { + constructor(message: string) { + super(message); + this.name = "ChatProviderConcurrentModificationError"; + } +} + export interface ListChatProviderConnectionsOptions { providerKind?: ChatProviderKind; enabledOnly?: boolean; @@ -146,44 +219,66 @@ export class ChatProviderRepository { } createConnection(input: CreateChatProviderConnectionInput): ChatProviderConnectionRecord { + if (input.secrets !== undefined && input.secrets !== null) { + throw new ValidationError("Connector secrets must be written through ChatProviderSecretService."); + } + return this.createConnectionRecord(input, randomUUID(), null, []); + } + + createConnectionWithEnvelope( + input: Omit, + connectionId: string, + envelope: StoredSecretEnvelope | null, + secretKeys: string[], + ): ChatProviderConnectionRecord { + if (envelope && envelope.credentialId !== connectionId) { + throw new ValidationError("Connector secret envelope id does not match its connection metadata."); + } + return this.createConnectionRecord(input, connectionId, envelope, secretKeys); + } + + private createConnectionRecord( + input: CreateChatProviderConnectionInput, + id: string, + envelope: StoredSecretEnvelope | null, + secretKeys: string[], + ): ChatProviderConnectionRecord { const providerKind = this.requireProviderKind(input.providerKind); const bridgeMode = this.resolveBridgeMode(providerKind, input.bridgeMode); const status = input.status ? this.requireConnectionStatus(input.status) : "draft"; const now = new Date().toISOString(); - const id = randomUUID(); const setup = this.sanitizeSetup(providerKind, input.setup ?? {}); - - this.db.prepare(` - INSERT INTO chat_provider_connections ( - id, provider_kind, display_name, bridge_mode, status, enabled, setup_json, secret_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - id, - providerKind, - this.requireNonEmpty(input.displayName, "displayName"), - bridgeMode, - status, - input.enabled === false ? 0 : 1, - this.stringifyJson(setup), - this.stringifyNullableJson(input.secrets ?? null), - now, - now, - ); - + this.db.transaction(() => { + this.db.prepare(` + INSERT INTO chat_provider_connections ( + id, provider_kind, display_name, bridge_mode, status, enabled, setup_json, secret_json, + verification_status, verification_details_json, verified_at, secret_version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'unverified', NULL, NULL, ?, ?, ?) + `).run( + id, + providerKind, + this.requireNonEmpty(input.displayName, "displayName"), + bridgeMode, + status, + input.enabled === false ? 0 : 1, + this.stringifyJson(setup), + envelope ? 1 : 0, + now, + now, + ); + if (envelope) { + this.putEnvelope(envelope, secretKeys); + } + }); return this.requireConnection(id); } updateConnection(connectionId: string, input: UpdateChatProviderConnectionInput): ChatProviderConnectionRecord { + if (input.secrets !== undefined) { + throw new ValidationError("Connector secrets must be written through ChatProviderSecretService."); + } const existing = this.requireConnectionInternal(connectionId); - const providerKind = existing.providerKind; - const bridgeMode = input.bridgeMode - ? this.resolveBridgeMode(providerKind, input.bridgeMode) - : existing.bridgeMode; - const status = input.status ? this.requireConnectionStatus(input.status) : existing.status; - const setup = input.setup !== undefined - ? this.sanitizeSetup(providerKind, input.setup) - : existing.setup; - const secrets = input.secrets !== undefined ? input.secrets : existing.secrets; + const update = this.prepareConnectionUpdate(existing, input); const now = new Date().toISOString(); this.db.prepare(` @@ -194,23 +289,69 @@ export class ChatProviderRepository { status = ?, enabled = ?, setup_json = ?, - secret_json = ?, + verification_status = CASE WHEN ? THEN 'unverified' ELSE verification_status END, + verification_details_json = CASE WHEN ? THEN NULL ELSE verification_details_json END, + verified_at = CASE WHEN ? THEN NULL ELSE verified_at END, updated_at = ? WHERE id = ? `).run( - input.displayName !== undefined ? this.requireNonEmpty(input.displayName, "displayName") : existing.displayName, - bridgeMode, - status, - input.enabled !== undefined ? (input.enabled ? 1 : 0) : (existing.enabled ? 1 : 0), - this.stringifyJson(setup), - this.stringifyNullableJson(secrets), + update.displayName, + update.bridgeMode, + update.status, + update.enabled ? 1 : 0, + this.stringifyJson(update.setup), + update.transportChanged ? 1 : 0, + update.transportChanged ? 1 : 0, + update.transportChanged ? 1 : 0, now, connectionId, ); - return this.requireConnection(connectionId); } + updateConnectionWithEnvelope( + connectionId: string, + input: Omit, + expectedSecretVersion: number, + envelope: StoredSecretEnvelope | null, + secretKeys: string[], + ): ChatProviderConnectionRecord { + if (envelope && envelope.credentialId !== connectionId) { + throw new ValidationError("Connector secret envelope id does not match its connection metadata."); + } + const existing = this.requireConnectionInternal(connectionId); + const update = this.prepareConnectionUpdate(existing, input); + const expectedVersion = this.requireNonNegativeInteger(expectedSecretVersion, "expectedSecretVersion"); + return this.db.transaction(() => { + const now = new Date().toISOString(); + const result = this.db.prepare(` + UPDATE chat_provider_connections + SET display_name = ?, bridge_mode = ?, status = ?, enabled = ?, setup_json = ?, + verification_status = 'unverified', verification_details_json = NULL, verified_at = NULL, + secret_version = secret_version + 1, secret_json = NULL, updated_at = ? + WHERE id = ? AND secret_version = ? + `).run( + update.displayName, + update.bridgeMode, + update.status, + update.enabled ? 1 : 0, + this.stringifyJson(update.setup), + now, + connectionId, + expectedVersion, + ); + if (result.changes !== 1) { + throw new ChatProviderConcurrentModificationError("Connector secrets changed concurrently; retry the operation."); + } + if (envelope) { + this.putEnvelope(envelope, secretKeys); + } else { + this.db.prepare("DELETE FROM chat_provider_connection_secrets WHERE provider_connection_id = ?").run(connectionId); + } + return this.requireConnection(connectionId); + }); + } + getConnection(connectionId: string): ChatProviderConnectionRecord | null { const row = this.getConnectionRow(connectionId); return row ? this.mapConnection(row) : null; @@ -233,10 +374,11 @@ export class ChatProviderRepository { } const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; const rows = this.db.prepare(` - SELECT * - FROM chat_provider_connections + SELECT c.*, s.secret_keys_json + FROM chat_provider_connections c + LEFT JOIN chat_provider_connection_secrets s ON s.provider_connection_id = c.id ${where} - ORDER BY updated_at DESC, display_name ASC + ORDER BY c.updated_at DESC, c.display_name ASC `).all(...params) as unknown as ChatProviderConnectionRow[]; return rows.map((row) => this.mapConnection(row)); } @@ -246,6 +388,87 @@ export class ChatProviderRepository { return result.changes > 0; } + getEnvelope(connectionId: string): StoredSecretEnvelope | null { + const row = this.db.prepare("SELECT * FROM chat_provider_connection_secrets WHERE provider_connection_id = ?") + .get(connectionId) as ChatProviderSecretRow | undefined; + return row ? { + credentialId: row.provider_connection_id, + ciphertext: row.ciphertext, + nonce: row.nonce, + authTag: row.auth_tag, + wrappedDataKey: row.wrapped_data_key, + wrapNonce: row.wrap_nonce, + wrapAuthTag: row.wrap_auth_tag, + keyId: row.key_id, + keyVersion: toNumber(row.key_version), + } : null; + } + + replaceSecretEnvelope( + connectionId: string, + expectedVersion: number, + envelope: StoredSecretEnvelope, + secretKeys: string[], + ): ChatProviderConnectionRecord { + return this.updateConnectionWithEnvelope(connectionId, {}, expectedVersion, envelope, secretKeys); + } + + clearConnectionSecrets(connectionId: string, expectedVersion: number): ChatProviderConnectionRecord { + return this.updateConnectionWithEnvelope(connectionId, {}, expectedVersion, null, []); + } + + updateVerification( + connectionId: string, + status: ChatProviderVerificationStatus, + details: Record | null, + ): ChatProviderConnectionRecord { + const verificationStatus = this.requireVerificationStatus(status); + const now = new Date().toISOString(); + const sanitizedDetails = details === null ? null : redactMetadata(details) as Record; + const update = this.db.prepare(` + UPDATE chat_provider_connections + SET verification_status = ?, verification_details_json = ?, verified_at = ?, updated_at = ? + WHERE id = ? + `).run( + verificationStatus, + this.stringifyNullableJson(sanitizedDetails), + verificationStatus === "verified" || verificationStatus === "failed" ? now : null, + now, + connectionId, + ); + if (update.changes !== 1) throw new EntityNotFoundError(`Chat provider connection not found: ${connectionId}`); + return this.requireConnection(connectionId); + } + + listLegacySecrets(): Array<{ connectionId: string; secretJson: string; secretVersion: number }> { + return this.db.prepare(` + SELECT id AS connectionId, secret_json AS secretJson, secret_version AS secretVersion + FROM chat_provider_connections + WHERE secret_json IS NOT NULL AND TRIM(secret_json) <> '' + ORDER BY created_at ASC + `).all() as Array<{ connectionId: string; secretJson: string; secretVersion: number }>; + } + + commitLegacySecretMigration( + connectionId: string, + expectedSecretJson: string, + expectedVersion: number, + envelope: StoredSecretEnvelope, + secretKeys: string[], + ): boolean { + if (envelope.credentialId !== connectionId) throw new ValidationError("Connector secret envelope id does not match its connection metadata."); + return this.db.transaction(() => { + const update = this.db.prepare(` + UPDATE chat_provider_connections + SET secret_json = NULL, secret_version = secret_version + 1, updated_at = ? + WHERE id = ? AND secret_json = ? AND secret_version = ? + `).run(new Date().toISOString(), connectionId, expectedSecretJson, expectedVersion); + if (update.changes !== 1) return false; + this.putEnvelope(envelope, secretKeys); + return true; + }); + } + createChannelBinding(input: CreateChatProviderChannelBindingInput): ChatProviderChannelBindingRecord { const connection = this.requireConnectionInternal(input.providerConnectionId); requireRecord(this.db.prepare("SELECT id FROM projects WHERE id = ?").get(input.projectId), "Project", input.projectId); @@ -397,18 +620,13 @@ export class ChatProviderRepository { recordInboundMessage(input: RecordInboundChatProviderMessageInput): RecordInboundChatProviderMessageResult { this.requireConnectionInternal(input.providerConnectionId); if (input.channelBindingId) { - this.requireChannelBinding(input.channelBindingId); + this.requireOwnedChannelBinding(input.channelBindingId, input.providerConnectionId); } const externalMessageId = this.requireNonEmpty(input.externalMessageId, "externalMessageId"); - const duplicate = this.findInboundDelivery(input.providerConnectionId, externalMessageId); - if (duplicate) { - return { delivery: duplicate, duplicate: true }; - } - const id = randomUUID(); const now = new Date().toISOString(); const status = input.status ? this.requireDeliveryStatus(input.status) : "processed"; - this.db.prepare(` + const insert = this.db.prepare(` INSERT INTO chat_provider_message_deliveries ( id, provider_connection_id, @@ -425,6 +643,8 @@ export class ChatProviderRepository { created_at, updated_at ) VALUES (?, ?, ?, ?, ?, 'inbound', ?, 0, NULL, ?, ?, ?, ?, ?) + ON CONFLICT(provider_connection_id, external_message_id) WHERE direction = 'inbound' AND external_message_id IS NOT NULL + DO NOTHING `).run( id, input.providerConnectionId, @@ -439,13 +659,17 @@ export class ChatProviderRepository { now, ); - return { delivery: this.requireDelivery(id), duplicate: false }; + if (insert.changes === 1) { + return { delivery: this.requireDelivery(id), duplicate: false }; + } + const duplicate = this.findInboundDelivery(input.providerConnectionId, externalMessageId); + return { delivery: requireRecord(duplicate, "Inbound chat provider delivery", externalMessageId), duplicate: true }; } upsertOutboundDelivery(input: UpsertOutboundChatProviderDeliveryInput): ChatProviderMessageDeliveryRecord { this.requireConnectionInternal(input.providerConnectionId); if (input.channelBindingId) { - this.requireChannelBinding(input.channelBindingId); + this.requireOwnedChannelBinding(input.channelBindingId, input.providerConnectionId); } const conversationMessageId = this.requireNonEmpty(input.conversationMessageId, "conversationMessageId"); const existing = this.getOutboundDeliveryByMessage(input.providerConnectionId, conversationMessageId); @@ -465,6 +689,9 @@ export class ChatProviderRepository { last_error = ?, conversation_thread_id = ?, payload_json = ?, + next_attempt_at = ?, + lease_owner = NULL, + lease_expires_at = NULL, updated_at = ? WHERE id = ? `).run( @@ -476,6 +703,7 @@ export class ChatProviderRepository { input.lastError !== undefined ? input.lastError : existing.lastError, input.conversationThreadId !== undefined ? input.conversationThreadId : existing.conversationThreadId, input.payload !== undefined ? this.stringifyNullableJson(input.payload) : this.stringifyNullableJson(existing.payload), + input.nextAttemptAt !== undefined ? input.nextAttemptAt : existing.nextAttemptAt, now, existing.id, ); @@ -497,9 +725,10 @@ export class ChatProviderRepository { conversation_thread_id, conversation_message_id, payload_json, + next_attempt_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, 'outbound', ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, 'outbound', ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, input.providerConnectionId, @@ -512,6 +741,7 @@ export class ChatProviderRepository { input.conversationThreadId ?? null, conversationMessageId, this.stringifyNullableJson(input.payload ?? null), + input.nextAttemptAt ?? null, now, now, ); @@ -532,6 +762,9 @@ export class ChatProviderRepository { conversation_thread_id = ?, conversation_message_id = ?, payload_json = ?, + next_attempt_at = ?, + lease_owner = CASE WHEN ? IN ('pending', 'retryable_failure', 'delivered', 'failed', 'cancelled') THEN NULL ELSE lease_owner END, + lease_expires_at = CASE WHEN ? IN ('pending', 'retryable_failure', 'delivered', 'failed', 'cancelled') THEN NULL ELSE lease_expires_at END, updated_at = ? WHERE id = ? `).run( @@ -542,6 +775,9 @@ export class ChatProviderRepository { input.conversationThreadId !== undefined ? input.conversationThreadId : existing.conversationThreadId, input.conversationMessageId !== undefined ? input.conversationMessageId : existing.conversationMessageId, input.payload !== undefined ? this.stringifyNullableJson(input.payload) : this.stringifyNullableJson(existing.payload), + input.nextAttemptAt !== undefined ? input.nextAttemptAt : existing.nextAttemptAt, + input.status, + input.status, now, deliveryId, ); @@ -589,9 +825,11 @@ export class ChatProviderRepository { INNER JOIN chat_provider_connections c ON c.id = d.provider_connection_id WHERE d.direction = 'outbound' AND d.status IN ('pending', 'sending', 'retryable_failure') + AND (d.next_attempt_at IS NULL OR d.next_attempt_at <= ?) + AND (d.lease_owner IS NULL OR d.lease_expires_at <= ?) ORDER BY d.updated_at ASC LIMIT ${boundedLimit} - `).all() as unknown as ChatProviderMessageDeliveryRow[]; + `).all(new Date().toISOString(), new Date().toISOString()) as unknown as ChatProviderMessageDeliveryRow[]; return rows.map((row) => this.mapDelivery(row)); } @@ -626,11 +864,199 @@ export class ChatProviderRepository { return rows.map((row) => this.mapDelivery(row)); } + insertIngressReplayReceipt( + providerConnectionId: string, + receiptKey: string, + expiresAt: string, + now = new Date(), + ): boolean { + this.requireConnectionInternal(providerConnectionId); + const normalizedKey = this.requireNonEmpty(receiptKey, "receiptKey"); + const normalizedExpiry = this.requireIsoDate(expiresAt, "expiresAt"); + return this.db.transaction(() => { + const nowIso = now.toISOString(); + this.db.prepare("DELETE FROM chat_provider_ingress_replay_receipts WHERE expires_at <= ?").run(nowIso); + const result = this.db.prepare(` + INSERT INTO chat_provider_ingress_replay_receipts ( + id, provider_connection_id, receipt_key, expires_at, created_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(provider_connection_id, receipt_key) DO NOTHING + `).run(randomUUID(), providerConnectionId, normalizedKey, normalizedExpiry, nowIso); + return result.changes === 1; + }); + } + + listIngressReplayReceipts(providerConnectionId: string): ChatProviderIngressReplayReceiptRecord[] { + const rows = this.db.prepare(` + SELECT * FROM chat_provider_ingress_replay_receipts + WHERE provider_connection_id = ? ORDER BY created_at ASC + `).all(providerConnectionId) as ChatProviderReplayReceiptRow[]; + return rows.map((row) => ({ + id: row.id, + providerConnectionId: row.provider_connection_id, + receiptKey: row.receipt_key, + expiresAt: row.expires_at, + createdAt: row.created_at, + })); + } + + cleanupExpiredIngressReplayReceipts(now = new Date()): number { + return this.db.prepare("DELETE FROM chat_provider_ingress_replay_receipts WHERE expires_at <= ?") + .run(now.toISOString()).changes; + } + + createProviderSession(input: CreateChatProviderSessionStateInput): ChatProviderSessionStateRecord { + this.requireConnectionInternal(input.providerConnectionId); + if (input.channelBindingId) this.requireOwnedChannelBinding(input.channelBindingId, input.providerConnectionId); + const now = new Date().toISOString(); + const id = randomUUID(); + this.db.prepare(` + INSERT INTO chat_provider_sessions ( + id, provider_connection_id, channel_binding_id, external_channel_id, session_key, + state_json, version, expires_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) + `).run( + id, + input.providerConnectionId, + input.channelBindingId ?? null, + this.requireNonEmpty(input.externalChannelId, "externalChannelId"), + this.requireNonEmpty(input.sessionKey, "sessionKey"), + this.stringifyJson(input.state), + input.expiresAt ? this.requireIsoDate(input.expiresAt, "expiresAt") : null, + now, + now, + ); + return this.requireProviderSession(id); + } + + getProviderSession(providerConnectionId: string, sessionKey: string): ChatProviderSessionStateRecord | null { + const row = this.db.prepare(` + SELECT * FROM chat_provider_sessions WHERE provider_connection_id = ? AND session_key = ? LIMIT 1 + `).get(providerConnectionId, this.requireNonEmpty(sessionKey, "sessionKey")) as ChatProviderSessionRow | undefined; + return row ? this.mapProviderSession(row) : null; + } + + compareAndSetProviderSession( + sessionId: string, + expectedVersion: number, + state: Record, + expiresAt?: string | null, + ): ChatProviderSessionStateRecord { + const existing = this.requireProviderSession(sessionId); + const result = this.db.prepare(` + UPDATE chat_provider_sessions + SET state_json = ?, version = version + 1, expires_at = ?, updated_at = ? + WHERE id = ? AND version = ? + `).run( + this.stringifyJson(state), + expiresAt === undefined ? existing.expiresAt : expiresAt === null ? null : this.requireIsoDate(expiresAt, "expiresAt"), + new Date().toISOString(), + sessionId, + this.requireNonNegativeInteger(expectedVersion, "expectedVersion"), + ); + if (result.changes !== 1) { + throw new ChatProviderConcurrentModificationError("Connector session changed concurrently; reload it before retrying."); + } + return this.requireProviderSession(sessionId); + } + + cleanupExpiredProviderSessions(now = new Date()): number { + return this.db.prepare("DELETE FROM chat_provider_sessions WHERE expires_at IS NOT NULL AND expires_at <= ?") + .run(now.toISOString()).changes; + } + + claimOutboundDeliveries(input: ClaimChatProviderDeliveriesInput): ChatProviderMessageDeliveryRecord[] { + const leaseOwner = this.requireNonEmpty(input.leaseOwner, "leaseOwner"); + if (!Number.isFinite(input.leaseDurationMs) || input.leaseDurationMs <= 0) { + throw new ValidationError("leaseDurationMs must be greater than zero"); + } + const limit = Math.max(1, Math.min(Math.trunc(input.limit ?? 1), 100)); + const now = input.now ?? new Date(); + const nowIso = now.toISOString(); + const leaseExpiresAt = new Date(now.getTime() + input.leaseDurationMs).toISOString(); + return this.db.transaction(() => { + const claimedIds: string[] = []; + for (let index = 0; index < limit; index += 1) { + const candidate = this.db.prepare(` + SELECT id FROM chat_provider_message_deliveries + WHERE direction = 'outbound' + AND status IN ('pending', 'sending', 'retryable_failure') + AND (next_attempt_at IS NULL OR next_attempt_at <= ?) + AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at <= ?) + ORDER BY COALESCE(next_attempt_at, created_at) ASC, created_at ASC + LIMIT 1 + `).get(nowIso, nowIso) as { id: string } | undefined; + if (!candidate) break; + const update = this.db.prepare(` + UPDATE chat_provider_message_deliveries + SET status = 'sending', lease_owner = ?, lease_expires_at = ?, updated_at = ? + WHERE id = ? + AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at <= ?) + `).run(leaseOwner, leaseExpiresAt, nowIso, candidate.id, nowIso); + if (update.changes === 1) claimedIds.push(candidate.id); + } + return claimedIds.map((id) => this.requireDelivery(id)); + }); + } + + completeOutboundDelivery( + deliveryId: string, + leaseOwner: string, + input: UpdateChatProviderDeliveryStateInput, + ): ChatProviderMessageDeliveryRecord { + const existing = this.requireDelivery(deliveryId); + const status = this.requireDeliveryStatus(input.status); + const update = this.db.prepare(` + UPDATE chat_provider_message_deliveries + SET status = ?, attempt_count = ?, last_error = ?, external_message_id = ?, + conversation_thread_id = ?, conversation_message_id = ?, payload_json = ?, next_attempt_at = ?, + lease_owner = NULL, lease_expires_at = NULL, updated_at = ? + WHERE id = ? AND direction = 'outbound' AND lease_owner = ? + `).run( + status, + input.attemptCount !== undefined ? this.requireNonNegativeInteger(input.attemptCount, "attemptCount") : existing.attemptCount, + input.lastError !== undefined ? input.lastError : existing.lastError, + input.externalMessageId !== undefined ? input.externalMessageId : existing.externalMessageId, + input.conversationThreadId !== undefined ? input.conversationThreadId : existing.conversationThreadId, + input.conversationMessageId !== undefined ? input.conversationMessageId : existing.conversationMessageId, + input.payload !== undefined ? this.stringifyNullableJson(input.payload) : this.stringifyNullableJson(existing.payload), + input.nextAttemptAt !== undefined ? input.nextAttemptAt : existing.nextAttemptAt, + new Date().toISOString(), + deliveryId, + this.requireNonEmpty(leaseOwner, "leaseOwner"), + ); + if (update.changes !== 1) throw new ChatProviderConcurrentModificationError("Outbound delivery lease is not owned by this worker."); + return this.requireDelivery(deliveryId); + } + + releaseOutboundDelivery( + deliveryId: string, + leaseOwner: string, + input: ReleaseChatProviderDeliveryInput = {}, + ): ChatProviderMessageDeliveryRecord { + const status = input.status ?? "pending"; + const update = this.db.prepare(` + UPDATE chat_provider_message_deliveries + SET status = ?, next_attempt_at = ?, last_error = ?, lease_owner = NULL, lease_expires_at = NULL, updated_at = ? + WHERE id = ? AND direction = 'outbound' AND lease_owner = ? + `).run( + status, + input.nextAttemptAt ?? null, + input.lastError ?? null, + new Date().toISOString(), + deliveryId, + this.requireNonEmpty(leaseOwner, "leaseOwner"), + ); + if (update.changes !== 1) throw new ChatProviderConcurrentModificationError("Outbound delivery lease is not owned by this worker."); + return this.requireDelivery(deliveryId); + } + private getConnectionRow(connectionId: string): ChatProviderConnectionRow | null { const row = this.db.prepare(` - SELECT * - FROM chat_provider_connections - WHERE id = ? + SELECT c.*, s.secret_keys_json + FROM chat_provider_connections c + LEFT JOIN chat_provider_connection_secrets s ON s.provider_connection_id = c.id + WHERE c.id = ? `).get(connectionId) as ChatProviderConnectionRow | undefined; return row ?? null; } @@ -657,6 +1083,23 @@ export class ChatProviderRepository { return requireRecord(this.getChannelBinding(bindingId), "Chat provider channel binding", bindingId); } + private requireOwnedChannelBinding(bindingId: string, providerConnectionId: string): ChatProviderChannelBindingRecord { + const binding = this.requireChannelBinding(bindingId); + if (binding.providerConnectionId !== providerConnectionId) { + throw new ValidationError("Channel binding does not belong to the referenced chat provider connection."); + } + return binding; + } + + private getProviderSessionRow(sessionId: string): ChatProviderSessionRow | null { + return (this.db.prepare("SELECT * FROM chat_provider_sessions WHERE id = ?").get(sessionId) as ChatProviderSessionRow | undefined) ?? null; + } + + private requireProviderSession(sessionId: string): ChatProviderSessionStateRecord { + const row = this.getProviderSessionRow(sessionId); + return requireRecord(row ? this.mapProviderSession(row) : null, "Chat provider session", sessionId); + } + private getDeliveryRow(deliveryId: string): ChatProviderMessageDeliveryRow | null { const row = this.db.prepare(` SELECT d.*, c.provider_kind @@ -686,6 +1129,7 @@ export class ChatProviderRepository { private mapConnection(row: ChatProviderConnectionRow): ChatProviderConnectionRecord { const internal = this.mapConnectionInternal(row); + const configuredSecrets = internal.secrets ?? this.secretKeyRecord(row.secret_keys_json); return { id: internal.id, providerKind: internal.providerKind, @@ -694,7 +1138,11 @@ export class ChatProviderRepository { status: internal.status, enabled: internal.enabled, setup: internal.setup, - credentials: this.redactCredentials(internal.providerKind, internal.bridgeMode, internal.secrets), + credentials: this.redactCredentials(internal.providerKind, internal.bridgeMode, configuredSecrets), + verificationStatus: internal.verificationStatus, + verificationDetails: internal.verificationDetails, + verifiedAt: internal.verifiedAt, + secretVersion: internal.secretVersion, createdAt: internal.createdAt, updatedAt: internal.updatedAt, }; @@ -702,6 +1150,7 @@ export class ChatProviderRepository { private mapConnectionInternal(row: ChatProviderConnectionRow): ChatProviderConnectionInternalRecord { const providerKind = this.requireProviderKind(row.provider_kind); + const secrets = this.parseJsonRecord(row.secret_json); return { id: row.id, providerKind, @@ -710,7 +1159,11 @@ export class ChatProviderRepository { status: this.requireConnectionStatus(row.status), enabled: toBoolean(row.enabled), setup: this.parseJsonRecord(row.setup_json) ?? {}, - secrets: this.parseJsonRecord(row.secret_json), + secrets: secrets ? { ...secrets } : null, + verificationStatus: this.requireVerificationStatus(row.verification_status), + verificationDetails: this.parseJsonRecord(row.verification_details_json), + verifiedAt: row.verified_at, + secretVersion: toNumber(row.secret_version), createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -751,6 +1204,24 @@ export class ChatProviderRepository { conversationThreadId: row.conversation_thread_id, conversationMessageId: row.conversation_message_id, payload: this.parseJsonRecord(row.payload_json), + nextAttemptAt: row.next_attempt_at, + leaseOwner: row.lease_owner, + leaseExpiresAt: row.lease_expires_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private mapProviderSession(row: ChatProviderSessionRow): ChatProviderSessionStateRecord { + return { + id: row.id, + providerConnectionId: row.provider_connection_id, + channelBindingId: row.channel_binding_id, + externalChannelId: row.external_channel_id, + sessionKey: row.session_key, + state: this.parseJsonRecord(row.state_json) ?? {}, + version: toNumber(row.version), + expiresAt: row.expires_at, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -794,6 +1265,33 @@ export class ChatProviderRepository { return sanitized; } + private prepareConnectionUpdate( + existing: ChatProviderConnectionInternalRecord, + input: Omit, + ): PreparedChatProviderConnectionUpdate { + const bridgeMode = input.bridgeMode + ? this.resolveBridgeMode(existing.providerKind, input.bridgeMode) + : existing.bridgeMode; + const status = input.status ? this.requireConnectionStatus(input.status) : existing.status; + const setup = input.setup !== undefined + ? this.sanitizeSetup(existing.providerKind, input.setup) + : existing.setup; + const enabled = input.enabled ?? existing.enabled; + return { + displayName: input.displayName !== undefined + ? this.requireNonEmpty(input.displayName, "displayName") + : existing.displayName, + bridgeMode, + status, + enabled, + setup, + transportChanged: bridgeMode !== existing.bridgeMode + || this.stringifyJson(setup) !== this.stringifyJson(existing.setup) + || enabled !== existing.enabled + || status !== existing.status, + }; + } + private resolveBridgeMode(providerKind: ChatProviderKind, bridgeMode: string | undefined): ChatProviderBridgeMode { const schema = getChatProviderSetupSchema(providerKind); const mode = bridgeMode ?? schema.defaultBridgeMode; @@ -818,6 +1316,13 @@ export class ChatProviderRepository { return value as ChatProviderConnectionStatus; } + private requireVerificationStatus(value: string): ChatProviderVerificationStatus { + if (!VERIFICATION_STATUSES.has(value as ChatProviderVerificationStatus)) { + throw new ValidationError(`Unsupported chat provider verification status: ${value}`); + } + return value as ChatProviderVerificationStatus; + } + private requireDeliveryStatus(value: string): ChatProviderDeliveryStatus { if (!DELIVERY_STATUSES.has(value as ChatProviderDeliveryStatus)) { throw new ValidationError(`Unsupported chat provider delivery status: ${value}`); @@ -847,6 +1352,12 @@ export class ChatProviderRepository { return value; } + private requireIsoDate(value: string, fieldName: string): string { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) throw new ValidationError(`${fieldName} must be a valid date`); + return date.toISOString(); + } + private parseJsonRecord(value: string | null | undefined): Record | null { if (!value || value.trim().length === 0) { return null; @@ -873,6 +1384,43 @@ export class ChatProviderRepository { private hasConfiguredSecret(value: unknown): boolean { return typeof value === "string" ? value.length > 0 : value !== null && value !== undefined; } + + private secretKeyRecord(value: string | null | undefined): ChatProviderSecretConfig | null { + if (!value) return null; + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) return null; + return Object.fromEntries(parsed.filter((key): key is string => typeof key === "string").map((key) => [key, true])); + } catch { + return null; + } + } + + private putEnvelope(envelope: StoredSecretEnvelope, secretKeys: string[]): void { + this.db.prepare(` + INSERT INTO chat_provider_connection_secrets ( + provider_connection_id, ciphertext, nonce, auth_tag, wrapped_data_key, wrap_nonce, wrap_auth_tag, + key_id, key_version, secret_keys_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider_connection_id) DO UPDATE SET + ciphertext = excluded.ciphertext, nonce = excluded.nonce, auth_tag = excluded.auth_tag, + wrapped_data_key = excluded.wrapped_data_key, wrap_nonce = excluded.wrap_nonce, + wrap_auth_tag = excluded.wrap_auth_tag, key_id = excluded.key_id, key_version = excluded.key_version, + secret_keys_json = excluded.secret_keys_json, updated_at = excluded.updated_at + `).run( + envelope.credentialId, + envelope.ciphertext, + envelope.nonce, + envelope.authTag, + envelope.wrappedDataKey, + envelope.wrapNonce, + envelope.wrapAuthTag, + envelope.keyId, + envelope.keyVersion, + JSON.stringify([...new Set(secretKeys)].sort()), + new Date().toISOString(), + ); + } } export { EntityNotFoundError }; diff --git a/src/repositories/db/app-db-migrations.ts b/src/repositories/db/app-db-migrations.ts index 9a002e7b05..62f6093508 100644 --- a/src/repositories/db/app-db-migrations.ts +++ b/src/repositories/db/app-db-migrations.ts @@ -69,6 +69,26 @@ export function ensureChatProviderTables(db: DatabaseAdapter): void { UNIQUE (provider_connection_id, external_channel_id, project_id) ) `); + ensureColumn(db, "chat_provider_connections", "verification_status", "TEXT NOT NULL DEFAULT 'unverified'"); + ensureColumn(db, "chat_provider_connections", "verification_details_json", "TEXT"); + ensureColumn(db, "chat_provider_connections", "verified_at", "TEXT"); + ensureColumn(db, "chat_provider_connections", "secret_version", "INTEGER NOT NULL DEFAULT 0"); + db.exec(` + CREATE TABLE IF NOT EXISTS chat_provider_connection_secrets ( + provider_connection_id TEXT PRIMARY KEY, + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + auth_tag BLOB NOT NULL, + wrapped_data_key BLOB NOT NULL, + wrap_nonce BLOB NOT NULL, + wrap_auth_tag BLOB NOT NULL, + key_id TEXT NOT NULL, + key_version INTEGER NOT NULL, + secret_keys_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL, + FOREIGN KEY (provider_connection_id) REFERENCES chat_provider_connections(id) ON DELETE CASCADE + ) + `); db.exec(` CREATE TABLE IF NOT EXISTS chat_provider_message_deliveries ( id TEXT PRIMARY KEY, @@ -91,6 +111,37 @@ export function ensureChatProviderTables(db: DatabaseAdapter): void { FOREIGN KEY (conversation_message_id) REFERENCES conversation_messages(id) ON DELETE SET NULL ) `); + ensureColumn(db, "chat_provider_message_deliveries", "next_attempt_at", "TEXT"); + ensureColumn(db, "chat_provider_message_deliveries", "lease_owner", "TEXT"); + ensureColumn(db, "chat_provider_message_deliveries", "lease_expires_at", "TEXT"); + db.exec(` + CREATE TABLE IF NOT EXISTS chat_provider_ingress_replay_receipts ( + id TEXT PRIMARY KEY, + provider_connection_id TEXT NOT NULL, + receipt_key TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (provider_connection_id) REFERENCES chat_provider_connections(id) ON DELETE CASCADE, + UNIQUE (provider_connection_id, receipt_key) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS chat_provider_sessions ( + id TEXT PRIMARY KEY, + provider_connection_id TEXT NOT NULL, + channel_binding_id TEXT, + external_channel_id TEXT NOT NULL, + session_key TEXT NOT NULL, + state_json TEXT NOT NULL DEFAULT '{}', + version INTEGER NOT NULL DEFAULT 1, + expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (provider_connection_id) REFERENCES chat_provider_connections(id) ON DELETE CASCADE, + FOREIGN KEY (channel_binding_id) REFERENCES chat_provider_channel_bindings(id) ON DELETE CASCADE, + UNIQUE (provider_connection_id, session_key) + ) + `); ensureIndex(db, "idx_chat_provider_connections_kind", "chat_provider_connections", "provider_kind, updated_at DESC"); ensureIndex(db, "idx_chat_provider_connections_enabled", "chat_provider_connections", "enabled, status, updated_at DESC"); @@ -109,9 +160,12 @@ export function ensureChatProviderTables(db: DatabaseAdapter): void { db.exec("DROP INDEX IF EXISTS idx_chat_provider_message_deliveries_pending_outbound"); db.exec(` CREATE INDEX IF NOT EXISTS idx_chat_provider_message_deliveries_pending_outbound - ON chat_provider_message_deliveries (status, updated_at ASC) + ON chat_provider_message_deliveries (status, next_attempt_at, lease_expires_at, updated_at ASC) WHERE direction = 'outbound' AND status IN ('pending', 'sending', 'retryable_failure') `); + ensureIndex(db, "idx_chat_provider_ingress_replay_expiry", "chat_provider_ingress_replay_receipts", "expires_at ASC"); + ensureIndex(db, "idx_chat_provider_sessions_connection", "chat_provider_sessions", "provider_connection_id, updated_at DESC"); + db.exec("CREATE INDEX IF NOT EXISTS idx_chat_provider_sessions_expiry ON chat_provider_sessions (expires_at ASC) WHERE expires_at IS NOT NULL"); } export function ensureTaskSelfReflectionRatingTables(db: DatabaseAdapter): void { diff --git a/src/repositories/db/app-db-schema.ts b/src/repositories/db/app-db-schema.ts index 7908a0667b..a89ccca6db 100644 --- a/src/repositories/db/app-db-schema.ts +++ b/src/repositories/db/app-db-schema.ts @@ -226,10 +226,29 @@ CREATE TABLE IF NOT EXISTS chat_provider_connections ( enabled INTEGER NOT NULL DEFAULT 1, setup_json TEXT NOT NULL DEFAULT '{}', secret_json TEXT, + verification_status TEXT NOT NULL DEFAULT 'unverified', + verification_details_json TEXT, + verified_at TEXT, + secret_version INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS chat_provider_connection_secrets ( + provider_connection_id TEXT PRIMARY KEY, + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + auth_tag BLOB NOT NULL, + wrapped_data_key BLOB NOT NULL, + wrap_nonce BLOB NOT NULL, + wrap_auth_tag BLOB NOT NULL, + key_id TEXT NOT NULL, + key_version INTEGER NOT NULL, + secret_keys_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL, + FOREIGN KEY (provider_connection_id) REFERENCES chat_provider_connections(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS chat_provider_channel_bindings ( id TEXT PRIMARY KEY, provider_connection_id TEXT NOT NULL, @@ -264,6 +283,9 @@ CREATE TABLE IF NOT EXISTS chat_provider_message_deliveries ( conversation_thread_id TEXT, conversation_message_id TEXT, payload_json TEXT, + next_attempt_at TEXT, + lease_owner TEXT, + lease_expires_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (provider_connection_id) REFERENCES chat_provider_connections(id) ON DELETE CASCADE, @@ -272,6 +294,32 @@ CREATE TABLE IF NOT EXISTS chat_provider_message_deliveries ( FOREIGN KEY (conversation_message_id) REFERENCES conversation_messages(id) ON DELETE SET NULL ); +CREATE TABLE IF NOT EXISTS chat_provider_ingress_replay_receipts ( + id TEXT PRIMARY KEY, + provider_connection_id TEXT NOT NULL, + receipt_key TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (provider_connection_id) REFERENCES chat_provider_connections(id) ON DELETE CASCADE, + UNIQUE (provider_connection_id, receipt_key) + ); + +CREATE TABLE IF NOT EXISTS chat_provider_sessions ( + id TEXT PRIMARY KEY, + provider_connection_id TEXT NOT NULL, + channel_binding_id TEXT, + external_channel_id TEXT NOT NULL, + session_key TEXT NOT NULL, + state_json TEXT NOT NULL DEFAULT '{}', + version INTEGER NOT NULL DEFAULT 1, + expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (provider_connection_id) REFERENCES chat_provider_connections(id) ON DELETE CASCADE, + FOREIGN KEY (channel_binding_id) REFERENCES chat_provider_channel_bindings(id) ON DELETE CASCADE, + UNIQUE (provider_connection_id, session_key) + ); + CREATE TABLE IF NOT EXISTS task_runs ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, @@ -1077,5 +1125,8 @@ CREATE INDEX IF NOT EXISTS idx_chat_provider_channel_bindings_project ON chat_pr CREATE INDEX IF NOT EXISTS idx_chat_provider_channel_bindings_provider_channel ON chat_provider_channel_bindings (provider_connection_id, external_channel_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_provider_message_deliveries_inbound_dedupe ON chat_provider_message_deliveries (provider_connection_id, external_message_id) WHERE direction = 'inbound' AND external_message_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_provider_message_deliveries_outbound_message ON chat_provider_message_deliveries (provider_connection_id, conversation_message_id) WHERE direction = 'outbound' AND conversation_message_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_chat_provider_message_deliveries_pending_outbound ON chat_provider_message_deliveries (status, updated_at ASC) WHERE direction = 'outbound' AND status IN ('pending', 'sending', 'retryable_failure'); +CREATE INDEX IF NOT EXISTS idx_chat_provider_message_deliveries_pending_outbound ON chat_provider_message_deliveries (status, next_attempt_at, lease_expires_at, updated_at ASC) WHERE direction = 'outbound' AND status IN ('pending', 'sending', 'retryable_failure'); +CREATE INDEX IF NOT EXISTS idx_chat_provider_ingress_replay_expiry ON chat_provider_ingress_replay_receipts (expires_at ASC); +CREATE INDEX IF NOT EXISTS idx_chat_provider_sessions_connection ON chat_provider_sessions (provider_connection_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_chat_provider_sessions_expiry ON chat_provider_sessions (expires_at ASC) WHERE expires_at IS NOT NULL; `; diff --git a/src/server/chat-provider-ingress-routes.ts b/src/server/chat-provider-ingress-routes.ts index 995957aa96..0de5d9297c 100644 --- a/src/server/chat-provider-ingress-routes.ts +++ b/src/server/chat-provider-ingress-routes.ts @@ -7,25 +7,26 @@ import { ChatProviderIngressSecurity, ChatProviderIngressSecurityError } from ". import { getChatConnectorProfileForMode } from "../domain/chat-connectors/registry.js"; import { redactText } from "../shared/security/redaction.js"; -const defaultSecurityVerifier = new ChatProviderIngressSecurity(); - export function registerChatProviderIngressRoutes(router: Express, deps: DashboardDependencies): void { if (!deps.chatProviderRepository || !deps.chatProviderIngressService) { return; } + const securityVerifier = new ChatProviderIngressSecurity(undefined, deps.chatProviderRepository); const handler = asyncRoute(async (req, res) => { const providerConnectionId = requireTrimmedString( req.params.providerConnectionId ?? req.params.connectionId, "providerConnectionId", ); - const connection = deps.chatProviderRepository!.getConnectionInternal(providerConnectionId); + const connection = deps.chatProviderSecretService + ? await deps.chatProviderSecretService.resolveConnection(providerConnectionId).catch(() => null) + : deps.chatProviderRepository!.getConnectionInternal(providerConnectionId); if (!connection) { throw new HttpRouteError(404, "Chat provider connection not found."); } try { - defaultSecurityVerifier.verify(connection, { + securityVerifier.verify(connection, { headers: req.headers, rawBody: buildRequestBodyForSignature(req), }); @@ -91,7 +92,9 @@ export function registerChatProviderIngressRoutes(router: Express, deps: Dashboa req.params.providerConnectionId ?? req.params.connectionId, "providerConnectionId", ); - const connection = deps.chatProviderRepository!.getConnectionInternal(providerConnectionId); + const connection = deps.chatProviderSecretService + ? await deps.chatProviderSecretService.resolveConnection(providerConnectionId).catch(() => null) + : deps.chatProviderRepository!.getConnectionInternal(providerConnectionId); if (!connection) { throw new HttpRouteError(404, "Chat provider connection not found."); } diff --git a/src/server/chat-provider-routes.ts b/src/server/chat-provider-routes.ts index bcbcfa3964..0637eae40a 100644 --- a/src/server/chat-provider-routes.ts +++ b/src/server/chat-provider-routes.ts @@ -1,6 +1,6 @@ import type { Express, Request } from "express"; import type { DashboardDependencies } from "./dashboard-server.js"; -import { syncRoute } from "./route-utils.js"; +import { asyncRoute, syncRoute } from "./route-utils.js"; import { parseChatProviderKind, parseCreateChatProviderChannelBindingInput, @@ -59,18 +59,21 @@ export function registerChatProviderRoutes(router: Express, deps: DashboardDepen res.json(decorateConnection(req, connection)); })); - router.post("/api/chat-providers/connections", syncRoute((req, res) => { - const created = repository.createConnection(parseCreateChatProviderConnectionInput(req.body)); + router.post("/api/chat-providers/connections", asyncRoute(async (req, res) => { + const input = parseCreateChatProviderConnectionInput(req.body); + const created = deps.chatProviderSecretService + ? await deps.chatProviderSecretService.createConnection(input) + : repository.createConnection(input); res.status(201).json(decorateConnection(req, created)); })); - router.patch("/api/chat-providers/connections/:connectionId", syncRoute((req, res) => { + router.patch("/api/chat-providers/connections/:connectionId", asyncRoute(async (req, res) => { const connectionId = requireTrimmedString(req.params.connectionId, "connectionId"); const existing = requireConnection(repository.getConnection(connectionId)); - const updated = repository.updateConnection( - connectionId, - parseUpdateChatProviderConnectionInput(req.body, existing), - ); + const input = parseUpdateChatProviderConnectionInput(req.body, existing); + const updated = deps.chatProviderSecretService + ? await deps.chatProviderSecretService.updateConnection(connectionId, input) + : repository.updateConnection(connectionId, input); res.json(decorateConnection(req, updated)); })); diff --git a/src/server/code-ux-server.ts b/src/server/code-ux-server.ts index 22edd18a57..2885fbd5dd 100644 --- a/src/server/code-ux-server.ts +++ b/src/server/code-ux-server.ts @@ -28,6 +28,7 @@ import { ProjectManagementRepository } from "../repositories/project-management- import { ProjectRuntimeRepository } from "../repositories/project-runtime-repository.js"; import { ConnectionChatRepository } from "../repositories/connection-chat-repository.js"; import { ChatProviderRepository } from "../repositories/chat-provider-repository.js"; +import type { ChatProviderSecretService } from "../services/chat-provider-secret-service.js"; import { ExecutionRepository } from "../repositories/execution-repository.js"; import { QaReviewRepository } from "../repositories/qa-review-repository.js"; import { AgentPresetRepository } from "../repositories/agent-preset-repository.js"; @@ -171,6 +172,7 @@ export class CodeUxServer { private projectRuntimeRepository: ProjectRuntimeRepository; private connectionChatRepository: ConnectionChatRepository; private chatProviderRepository: ChatProviderRepository; + private chatProviderSecretService: ChatProviderSecretService; private projectWorkerAssignmentRepository: ProjectWorkerAssignmentRepository; private projectWorkerAssignmentService: ProjectWorkerAssignmentService; private projectAttentionRepository: ProjectAttentionRepository; @@ -266,6 +268,7 @@ export class CodeUxServer { this.projectRuntimeRepository = deps.projectRuntimeRepository; this.connectionChatRepository = deps.connectionChatRepository; this.chatProviderRepository = deps.chatProviderRepository; + this.chatProviderSecretService = deps.chatProviderSecretService; this.projectWorkerAssignmentRepository = deps.projectWorkerAssignmentRepository; this.projectWorkerAssignmentService = deps.projectWorkerAssignmentService; this.projectAttentionRepository = deps.projectAttentionRepository; @@ -1432,6 +1435,7 @@ export class CodeUxServer { getDashboardNotifications: () => this.executionRepository.getDashboardNotifications(), connectionChatRepository: this.connectionChatRepository, chatProviderRepository: this.chatProviderRepository, + chatProviderSecretService: this.chatProviderSecretService, projectWorkerAssignmentRepository: this.projectWorkerAssignmentRepository, projectWorkerAssignmentService: this.projectWorkerAssignmentService, projectAttentionRepository: this.projectAttentionRepository, diff --git a/src/server/dashboard-server.ts b/src/server/dashboard-server.ts index e1e9af0703..4c74af7ed4 100644 --- a/src/server/dashboard-server.ts +++ b/src/server/dashboard-server.ts @@ -137,6 +137,7 @@ import type { UpdateStatus } from "../services/update-checker-service.js"; import type { LocalMcpCliProvider, LocalMcpInstallResult, LocalMcpSetupInfo } from "../services/local-mcp-cli-config-service.js"; import { resolveDashboardBindHost } from "../config/app-config.js"; import type { ChatProviderIngressService } from "../services/chat-provider-ingress-service.js"; +import type { ChatProviderSecretService } from "../services/chat-provider-secret-service.js"; import type { SpeechTranscriptionService } from "../services/speech-transcription-service.js"; import type { SpeechSynthesisService } from "../services/speech-synthesis-service.js"; import type { SpeechModelManager } from "../services/speech-model-manager.js"; @@ -191,6 +192,7 @@ export interface DashboardServerOptions { knowledgeService?: KnowledgeService; agentPresetRepository?: AgentPresetRepository; chatProviderRepository?: ChatProviderRepository; + chatProviderSecretService?: ChatProviderSecretService; chatProviderIngressService?: ChatProviderIngressService; speechTranscriptionService?: SpeechTranscriptionService; speechSynthesisService?: SpeechSynthesisService; diff --git a/src/services/chat-provider-ingress-service.ts b/src/services/chat-provider-ingress-service.ts index 69b783b9f9..bdd02fa049 100644 --- a/src/services/chat-provider-ingress-service.ts +++ b/src/services/chat-provider-ingress-service.ts @@ -12,6 +12,7 @@ import { getCorrelationId } from "../shared/logging/correlation-id.js"; import { redactMetadata } from "../shared/security/redaction.js"; import { getChatConnectorProfileForMode } from "../domain/chat-connectors/registry.js"; import type { PartialNormalizedChatConnectorInbound } from "../domain/chat-connectors/types.js"; +import type { ChatProviderSecretService } from "./chat-provider-secret-service.js"; export interface ChatProviderIngressPayload { providerConnectionId: string; @@ -51,6 +52,7 @@ export interface ChatProviderIngressResult { interface ChatProviderIngressServiceDependencies { chatProviderRepository: ChatProviderRepository; + chatProviderSecretService?: ChatProviderSecretService; chatThreadRuntimeService: ChatThreadRuntimeService; logger?: Logger; } @@ -78,7 +80,9 @@ export class ChatProviderIngressService { constructor(private readonly deps: ChatProviderIngressServiceDependencies) {} async processInbound(input: ChatProviderIngressPayload): Promise { - const connection = this.deps.chatProviderRepository.getConnectionInternal(input.providerConnectionId); + const connection = this.deps.chatProviderSecretService + ? await this.deps.chatProviderSecretService.resolveConnection(input.providerConnectionId).catch(() => null) + : this.deps.chatProviderRepository.getConnectionInternal(input.providerConnectionId); if (!connection) { this.log("warn", "Rejected chat provider ingress for unknown connection", { providerConnectionId: input.providerConnectionId, diff --git a/src/services/chat-provider-outbound-service.ts b/src/services/chat-provider-outbound-service.ts index 7730f1121a..385b4d3710 100644 --- a/src/services/chat-provider-outbound-service.ts +++ b/src/services/chat-provider-outbound-service.ts @@ -17,6 +17,8 @@ import { type ChatProviderOutboundBridgePayload, } from "./chat-provider-adapters.js"; import { stripDashboardOnlyWidgets } from "./chat-reply-prompt.js"; +import type { ChatProviderSecretService } from "./chat-provider-secret-service.js"; +import { randomUUID } from "node:crypto"; export interface DeliverChatProviderReplyInput { projectId: string; @@ -27,6 +29,7 @@ export interface DeliverChatProviderReplyInput { interface ChatProviderOutboundServiceDependencies { chatProviderRepository: ChatProviderRepository; + chatProviderSecretService?: ChatProviderSecretService; adapter?: ChatProviderOutboundAdapter; logger?: Logger; pollIntervalMs?: number; @@ -43,6 +46,7 @@ interface RetryMetadata { const DEFAULT_POLL_INTERVAL_MS = 30_000; const DEFAULT_INITIAL_BACKOFF_MS = 30_000; const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_LEASE_DURATION_MS = 60_000; export class ChatProviderOutboundService { private readonly adapter: ChatProviderOutboundAdapter; @@ -52,6 +56,7 @@ export class ChatProviderOutboundService { private readonly now: () => Date; private timer: NodeJS.Timeout | null = null; private retryProcessingPromise: Promise | null = null; + private readonly leaseOwner = `chat-provider-outbound:${randomUUID()}`; constructor(private readonly deps: ChatProviderOutboundServiceDependencies) { this.adapter = deps.adapter ?? createDefaultChatProviderOutboundAdapter(); @@ -114,6 +119,7 @@ export class ChatProviderOutboundService { status: "pending", attemptCount: 0, lastError: null, + nextAttemptAt: null, payload: { ...payload, delivery: { @@ -149,27 +155,32 @@ export class ChatProviderOutboundService { } private async processDueRetriesOnce(limit: number): Promise { - const now = this.now().toISOString(); - const due = this.deps.chatProviderRepository.listPendingOutboundDeliveries(limit) - .filter((delivery) => delivery.status !== "retryable_failure" || getNextAttemptAt(delivery) <= now); + const due = this.deps.chatProviderRepository.claimOutboundDeliveries({ + leaseOwner: this.leaseOwner, + leaseDurationMs: DEFAULT_LEASE_DURATION_MS, + limit, + now: this.now(), + }); const results: ChatProviderMessageDeliveryRecord[] = []; for (const delivery of due) { - results.push(await this.attemptDelivery(delivery.id)); + results.push(await this.attemptDelivery(delivery.id, this.leaseOwner)); } return results; } - async attemptDelivery(deliveryId: string): Promise { + async attemptDelivery(deliveryId: string, leaseOwner?: string): Promise { const delivery = requireDelivery(this.deps.chatProviderRepository.getDelivery(deliveryId), deliveryId); - const connection = this.deps.chatProviderRepository.getConnectionInternal(delivery.providerConnectionId); + const connection = this.deps.chatProviderSecretService + ? await this.deps.chatProviderSecretService.resolveConnection(delivery.providerConnectionId).catch(() => null) + : this.deps.chatProviderRepository.getConnectionInternal(delivery.providerConnectionId); if (!connection || !connection.enabled || connection.status === "disabled") { - return this.markTerminalFailure(delivery, "Chat provider connection is disabled or missing.", getPayload(delivery)); + return this.markTerminalFailure(delivery, "Chat provider connection is disabled or missing.", getPayload(delivery), leaseOwner); } const binding = delivery.channelBindingId ? this.deps.chatProviderRepository.getChannelBinding(delivery.channelBindingId) : null; if (!binding || !binding.enabled || !binding.outboundEnabled) { - return this.markTerminalFailure(delivery, "Outbound channel binding is disabled or missing.", getPayload(delivery)); + return this.markTerminalFailure(delivery, "Outbound channel binding is disabled or missing.", getPayload(delivery), leaseOwner); } const payload = normalizePayload(delivery, binding); const attemptCount = delivery.attemptCount + 1; @@ -191,6 +202,7 @@ export class ChatProviderOutboundService { status: "sending", attemptCount, lastError: null, + nextAttemptAt: null, payload: withDeliveryState(payload, { retryable: false, nextAttemptAt: null, @@ -205,10 +217,11 @@ export class ChatProviderOutboundService { payload, correlationId, }); - const delivered = this.deps.chatProviderRepository.updateDeliveryState(delivery.id, { + const completion = { status: "delivered", externalMessageId: result.externalMessageId ?? delivery.externalMessageId ?? null, lastError: null, + nextAttemptAt: null, payload: { ...payload, bridgeResponse: result.responseMetadata ?? null, @@ -218,7 +231,10 @@ export class ChatProviderOutboundService { nextAttemptAt: null, }, }, - }); + } as const; + const delivered = leaseOwner + ? this.deps.chatProviderRepository.completeOutboundDelivery(delivery.id, leaseOwner, completion) + : this.deps.chatProviderRepository.updateDeliveryState(delivery.id, completion); this.log("info", "Delivered chat provider outbound reply", { correlationId, providerConnectionId: connection.id, @@ -236,12 +252,16 @@ export class ChatProviderOutboundService { const nextAttemptAt = retryable ? this.computeNextAttemptAt(attemptCount, adapterError.retryAfterMs).toISOString() : null; - const failed = this.deps.chatProviderRepository.updateDeliveryState(delivery.id, { + const completion = { status: retryable ? "retryable_failure" : "failed", attemptCount, lastError: adapterError.message, + nextAttemptAt, payload: withDeliveryState(payload, { retryable, nextAttemptAt }, retryable ? "retryable_failure" : "failed"), - }); + } as const; + const failed = leaseOwner + ? this.deps.chatProviderRepository.completeOutboundDelivery(delivery.id, leaseOwner, completion) + : this.deps.chatProviderRepository.updateDeliveryState(delivery.id, completion); this.log(retryable ? "warn" : "error", retryable ? "Chat provider outbound delivery failed and will retry" : "Chat provider outbound delivery failed permanently", { @@ -286,12 +306,17 @@ export class ChatProviderOutboundService { delivery: ChatProviderMessageDeliveryRecord, message: string, payload: ChatProviderOutboundBridgePayload, + leaseOwner?: string, ): ChatProviderMessageDeliveryRecord { - const failed = this.deps.chatProviderRepository.updateDeliveryState(delivery.id, { + const completion = { status: "failed", lastError: redactText(message), + nextAttemptAt: null, payload: withDeliveryState(payload, { retryable: false, nextAttemptAt: null }, "failed"), - }); + } as const; + const failed = leaseOwner + ? this.deps.chatProviderRepository.completeOutboundDelivery(delivery.id, leaseOwner, completion) + : this.deps.chatProviderRepository.updateDeliveryState(delivery.id, completion); this.log("error", "Chat provider outbound delivery could not be attempted", { providerConnectionId: delivery.providerConnectionId, providerKind: delivery.providerKind, @@ -374,13 +399,3 @@ function withDeliveryState( }, }; } - -function getNextAttemptAt(delivery: ChatProviderMessageDeliveryRecord): string { - const nextAttemptAt = delivery.payload?.delivery - && typeof delivery.payload.delivery === "object" - && !Array.isArray(delivery.payload.delivery) - && typeof (delivery.payload.delivery as Record).nextAttemptAt === "string" - ? (delivery.payload.delivery as Record).nextAttemptAt as string - : ""; - return nextAttemptAt || "0000-01-01T00:00:00.000Z"; -} diff --git a/src/services/chat-provider-secret-service.ts b/src/services/chat-provider-secret-service.ts new file mode 100644 index 0000000000..8dc2b9a9f6 --- /dev/null +++ b/src/services/chat-provider-secret-service.ts @@ -0,0 +1,204 @@ +import { randomUUID } from "node:crypto"; +import type { + ChatProviderConnectionInternalRecord, + ChatProviderConnectionRecord, + ChatProviderSecretConfig, + ChatProviderVerificationStatus, + CreateChatProviderConnectionInput, + UpdateChatProviderConnectionInput, +} from "../contracts/chat-provider-types.js"; +import { EncryptedSqliteSecretStore } from "../infrastructure/security/encrypted-sqlite-secret-store.js"; +import type { ChatProviderRepository } from "../repositories/chat-provider-repository.js"; +import type { KeyProvider } from "./credentials/key-provider.js"; +import type { SecretContext, SecretStore } from "./credentials/secret-store.js"; + +const CONNECTOR_SECRET_PROJECT = "chat-provider-connectors"; +const CONNECTOR_SECRET_WORKSPACE = "global"; + +export interface ChatProviderSecretMigrationFailure { + connectionId: string; + reason: string; +} + +export interface ChatProviderSecretMigrationResult { + status: "ready" | "blocked" | "partial"; + migrated: number; + pending: number; + failures: ChatProviderSecretMigrationFailure[]; + reason?: string; +} + +export class ChatProviderSecretService { + private readonly secretStore: SecretStore; + + constructor( + private readonly repository: ChatProviderRepository, + private readonly keyProvider: KeyProvider, + secretStore?: SecretStore, + ) { + this.secretStore = secretStore ?? new EncryptedSqliteSecretStore(repository, keyProvider); + } + + async createConnection(input: CreateChatProviderConnectionInput): Promise { + const connectionId = randomUUID(); + const secrets = normalizeSecrets(input.secrets ?? null); + const envelope = secrets ? await this.seal(connectionId, secrets) : null; + const { secrets: _secrets, ...metadata } = input; + return this.repository.createConnectionWithEnvelope(metadata, connectionId, envelope, configuredSecretKeys(secrets)); + } + + async updateConnection( + connectionId: string, + input: UpdateChatProviderConnectionInput, + ): Promise { + const existing = this.requireConnection(connectionId); + let envelope: Awaited> | null | undefined; + let secretKeys: string[] = []; + if (input.secrets !== undefined) { + const normalized = normalizeSecrets(input.secrets); + envelope = normalized ? await this.seal(connectionId, normalized) : null; + secretKeys = configuredSecretKeys(normalized); + } + + const { secrets: _secrets, ...metadata } = input; + if (envelope === undefined) return this.repository.updateConnection(connectionId, metadata); + return this.repository.updateConnectionWithEnvelope( + connectionId, + metadata, + existing.secretVersion, + envelope, + secretKeys, + ); + } + + async resolveConnection(connectionId: string): Promise { + const connection = this.repository.getConnectionInternal(connectionId); + if (!connection) throw new Error(`Chat provider connection not found: ${connectionId}`); + const envelope = this.repository.getEnvelope(connectionId); + if (!envelope) { + return { ...connection, secrets: connection.secrets ? { ...connection.secrets } : null }; + } + const plaintext = await this.secretStore.get(this.context(connectionId)); + try { + return { ...connection, secrets: parseSecrets(plaintext.toString("utf8"), connectionId) }; + } finally { + plaintext.fill(0); + } + } + + updateVerification( + connectionId: string, + status: ChatProviderVerificationStatus, + details: Record | null, + ): ChatProviderConnectionRecord { + return this.repository.updateVerification(connectionId, status, details); + } + + async migrateLegacySecrets(): Promise { + const pendingAtStart = this.repository.listLegacySecrets(); + if (pendingAtStart.length === 0) { + return { status: "ready", migrated: 0, pending: 0, failures: [] }; + } + let health; + try { + health = await this.keyProvider.health(); + } catch (error) { + return blockedResult(pendingAtStart.length, error); + } + if (!health.available || !health.secure) { + return { + status: "blocked", + migrated: 0, + pending: pendingAtStart.length, + failures: [], + reason: health.reason ?? `Secure connector key provider ${health.provider} is not ready.`, + }; + } + + let migrated = 0; + const failures: ChatProviderSecretMigrationFailure[] = []; + for (const legacy of pendingAtStart) { + let plaintext: Buffer | null = null; + try { + const secrets = parseSecrets(legacy.secretJson, legacy.connectionId); + plaintext = Buffer.from(JSON.stringify(secrets), "utf8"); + const envelope = await this.secretStore.seal(this.context(legacy.connectionId), plaintext); + const committed = this.repository.commitLegacySecretMigration( + legacy.connectionId, + legacy.secretJson, + legacy.secretVersion, + envelope, + configuredSecretKeys(secrets), + ); + if (committed) migrated += 1; + } catch (error) { + failures.push({ + connectionId: legacy.connectionId, + reason: error instanceof Error ? error.message : "Connector secret encryption failed.", + }); + } finally { + plaintext?.fill(0); + } + } + const pending = this.repository.listLegacySecrets().length; + return { + status: pending === 0 ? "ready" : migrated > 0 ? "partial" : "blocked", + migrated, + pending, + failures, + ...(pending > 0 ? { reason: "Some legacy connector secrets remain unsealed; restore key readiness and rerun the migration." } : {}), + }; + } + + private async seal(connectionId: string, secrets: ChatProviderSecretConfig) { + const plaintext = Buffer.from(JSON.stringify(secrets), "utf8"); + try { + return await this.secretStore.seal(this.context(connectionId), plaintext); + } finally { + plaintext.fill(0); + } + } + + private context(connectionId: string): SecretContext { + return { + credentialId: connectionId, + projectId: CONNECTOR_SECRET_PROJECT, + workspaceId: CONNECTOR_SECRET_WORKSPACE, + }; + } + + private requireConnection(connectionId: string): ChatProviderConnectionRecord { + const connection = this.repository.getConnection(connectionId); + if (!connection) throw new Error(`Chat provider connection not found: ${connectionId}`); + return connection; + } +} + +function normalizeSecrets(secrets: ChatProviderSecretConfig | null): ChatProviderSecretConfig | null { + if (!secrets) return null; + return Object.keys(secrets).length > 0 ? { ...secrets } : null; +} + +function configuredSecretKeys(secrets: ChatProviderSecretConfig | null): string[] { + return Object.entries(secrets ?? {}) + .filter(([, value]) => typeof value === "string" ? value.length > 0 : value !== null && value !== undefined) + .map(([key]) => key); +} + +function parseSecrets(value: string, connectionId: string): ChatProviderSecretConfig { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Legacy connector secret for ${connectionId} is not a JSON object.`); + } + return parsed as ChatProviderSecretConfig; +} + +function blockedResult(pending: number, error: unknown): ChatProviderSecretMigrationResult { + return { + status: "blocked", + migrated: 0, + pending, + failures: [], + reason: error instanceof Error ? error.message : "Secure connector key provider readiness could not be checked.", + }; +} diff --git a/src/services/chat-provider-security.ts b/src/services/chat-provider-security.ts index 37bb09a4ab..450d30b9ba 100644 --- a/src/services/chat-provider-security.ts +++ b/src/services/chat-provider-security.ts @@ -25,12 +25,19 @@ interface ReplayEntry { expiresAt: number; } +export interface ChatProviderReplayReceiptStore { + insertIngressReplayReceipt(providerConnectionId: string, receiptKey: string, expiresAt: string, now?: Date): boolean; +} + const DEFAULT_TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; const MAX_REPLAY_CACHE_SIZE = 2_000; export class ChatProviderIngressSecurity { private readonly replayCache = new Map(); - constructor(private readonly timestampToleranceMs = DEFAULT_TIMESTAMP_TOLERANCE_MS) {} + constructor( + private readonly timestampToleranceMs = DEFAULT_TIMESTAMP_TOLERANCE_MS, + private readonly replayReceiptStore?: ChatProviderReplayReceiptStore, + ) {} verify( connection: ChatProviderConnectionInternalRecord, @@ -161,6 +168,18 @@ export class ChatProviderIngressSecurity { } private preventReplay(input: { connectionId: string; key: string; nowMs: number }): void { + if (this.replayReceiptStore) { + const inserted = this.replayReceiptStore.insertIngressReplayReceipt( + input.connectionId, + input.key, + new Date(input.nowMs + this.timestampToleranceMs).toISOString(), + new Date(input.nowMs), + ); + if (!inserted) { + throw new ChatProviderIngressSecurityError("replay_detected", "Duplicate chat provider ingress request.", 409); + } + return; + } this.pruneReplayCache(input.nowMs); const replayKey = `${input.connectionId}:${input.key}`; if (this.replayCache.has(replayKey)) { diff --git a/src/services/chat-providers/discord-gateway-session.ts b/src/services/chat-providers/discord-gateway-session.ts deleted file mode 100644 index 0ff15d7eb3..0000000000 --- a/src/services/chat-providers/discord-gateway-session.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { - DISCORD_GATEWAY_URL, - normalizeDiscordGatewayEvent, - type DiscordInboundEvent, -} from "../../domain/chat-connectors/providers/discord.js"; - -export interface DiscordGatewaySessionState { - sessionId: string; - resumeGatewayUrl: string; - sequence: number; - botUserId?: string; -} - -/** Shared persistence boundary; implementations may use SQLite, memory, or another durable store. */ -export interface DiscordGatewaySessionStore { - load(connectionId: string): Promise; - save(connectionId: string, state: DiscordGatewaySessionState): Promise; - clear(connectionId: string): Promise; -} - -export interface DiscordGatewayConnection { - send(payload: string): void | Promise; - close(code?: number, reason?: string): void | Promise; -} - -export interface DiscordGatewayTransportHandlers { - onMessage(payload: string): void | Promise; - onClose(code?: number, reason?: string): void | Promise; - onError(error: unknown): void | Promise; -} - -export interface DiscordGatewayTransport { - connect( - url: string, - handlers: DiscordGatewayTransportHandlers, - signal: AbortSignal, - ): Promise; -} - -export type DiscordGatewayFailureCode = - | "invalid_auth" - | "invalid_intents" - | "missing_privileged_intent" - | "invalid_shard" - | "sharding_required" - | "invalid_api_version" - | "reconnect_exhausted" - | "transport_failure" - | "malformed_gateway_payload"; - -export class DiscordGatewaySessionError extends Error { - constructor(readonly code: DiscordGatewayFailureCode, message: string, readonly retryable: boolean) { - super(message); - this.name = "DiscordGatewaySessionError"; - } -} - -export interface DiscordGatewaySessionOptions { - connectionId: string; - botToken: string; - intents: number; - sessionStore: DiscordGatewaySessionStore; - transport: DiscordGatewayTransport; - onMessage(event: Extract): void | Promise; - onFailure?(error: DiscordGatewaySessionError): void | Promise; - random?: () => number; - wait?: (delayMs: number, signal: AbortSignal) => Promise; - setTimer?: (callback: () => void, delayMs: number) => unknown; - clearTimer?: (timer: unknown) => void; - initialBackoffMs?: number; - maxBackoffMs?: number; - maxReconnectAttempts?: number; - gatewayUrl?: string; -} - -interface GatewayPayload { - op: number; - d?: unknown; - s?: number | null; - t?: string | null; -} - -interface ReconnectDecision { - reconnect: boolean; - resumable: boolean; - error?: DiscordGatewaySessionError; -} - -const NON_RESUMABLE_CLOSE_CODES = new Set([4001, 4002, 4003, 4005, 4007, 4009]); - -export class DiscordGatewaySession { - private readonly controller = new AbortController(); - private readonly random: () => number; - private readonly wait: (delayMs: number, signal: AbortSignal) => Promise; - private readonly setTimer: (callback: () => void, delayMs: number) => unknown; - private readonly clearTimer: (timer: unknown) => void; - private readonly initialBackoffMs: number; - private readonly maxBackoffMs: number; - private readonly maxReconnectAttempts: number; - private readonly gatewayUrl: string; - private state: DiscordGatewaySessionState | null = null; - private currentConnection: DiscordGatewayConnection | null = null; - private heartbeatTimer: unknown | null = null; - private heartbeatIntervalMs = 0; - private heartbeatAcknowledged = true; - private runPromise: Promise | null = null; - private reconnectAttempt = 0; - - constructor(private readonly options: DiscordGatewaySessionOptions) { - if (!options.connectionId.trim()) throw new Error("Discord connection ID is required."); - if (!options.botToken.trim()) throw new Error("Discord bot token is required."); - if (!Number.isSafeInteger(options.intents) || options.intents < 0) throw new Error("Discord intents must be a non-negative integer."); - this.random = options.random ?? Math.random; - this.wait = options.wait ?? abortableWait; - this.setTimer = options.setTimer ?? ((callback, delayMs) => { - const timer = setTimeout(callback, delayMs); - timer.unref?.(); - return timer; - }); - this.clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer as NodeJS.Timeout)); - this.initialBackoffMs = options.initialBackoffMs ?? 1_000; - this.maxBackoffMs = options.maxBackoffMs ?? 30_000; - this.maxReconnectAttempts = options.maxReconnectAttempts ?? 8; - this.gatewayUrl = normalizeGatewayUrl(options.gatewayUrl) ?? DISCORD_GATEWAY_URL; - } - - start(signal?: AbortSignal): Promise { - if (this.runPromise) return this.runPromise; - if (signal?.aborted) this.controller.abort(signal.reason); - else signal?.addEventListener("abort", () => this.controller.abort(signal.reason), { once: true }); - this.runPromise = this.runLoop().finally(() => { - this.clearHeartbeat(); - this.currentConnection = null; - }); - return this.runPromise; - } - - async stop(): Promise { - if (!this.controller.signal.aborted) this.controller.abort(new Error("Discord Gateway session stopped.")); - this.clearHeartbeat(); - const connection = this.currentConnection; - this.currentConnection = null; - if (connection) await connection.close(1000, "Code UX shutdown"); - await this.runPromise; - this.state = null; - await this.options.sessionStore.clear(this.options.connectionId); - } - - private async runLoop(): Promise { - this.state = sanitizeSessionState(await this.options.sessionStore.load(this.options.connectionId)); - let shouldResume = this.state !== null; - while (!this.controller.signal.aborted) { - const decision = await this.runConnection(shouldResume); - if (this.controller.signal.aborted || !decision.reconnect) { - if (decision.error) await this.reportFailure(decision.error); - this.state = null; - await this.options.sessionStore.clear(this.options.connectionId); - return; - } - if (!decision.resumable) { - this.state = null; - await this.options.sessionStore.clear(this.options.connectionId); - } - shouldResume = decision.resumable && this.state !== null; - if (decision.error && !decision.error.retryable) { - await this.reportFailure(decision.error); - throw decision.error; - } - if (this.reconnectAttempt >= this.maxReconnectAttempts) { - const exhausted = new DiscordGatewaySessionError( - "reconnect_exhausted", - "Discord Gateway reconnect attempts were exhausted.", - false, - ); - await this.reportFailure(exhausted); - throw exhausted; - } - const delayMs = Math.min( - this.maxBackoffMs, - this.initialBackoffMs * Math.pow(2, this.reconnectAttempt), - ); - this.reconnectAttempt += 1; - try { - await this.wait(delayMs, this.controller.signal); - } catch { - if (this.controller.signal.aborted) return; - throw new DiscordGatewaySessionError("transport_failure", "Discord Gateway reconnect delay failed.", false); - } - } - } - - private async runConnection(shouldResume: boolean): Promise { - let settle!: (decision: ReconnectDecision) => void; - let settled = false; - const completed = new Promise((resolve) => { - settle = (decision) => { - if (settled) return; - settled = true; - resolve(decision); - }; - }); - const requestReconnect = async (decision: ReconnectDecision, closeCode = 4000, reason = "Reconnect"): Promise => { - settle(decision); - const connection = this.currentConnection; - if (connection) await connection.close(closeCode, reason); - }; - const abort = (): void => { - settle({ reconnect: false, resumable: false }); - void this.currentConnection?.close(1000, "Code UX cancellation"); - }; - this.controller.signal.addEventListener("abort", abort, { once: true }); - - try { - const resumeUrl = shouldResume ? normalizeGatewayUrl(this.state?.resumeGatewayUrl) : null; - const url = resumeUrl ?? this.gatewayUrl; - const connection = await this.options.transport.connect(url, { - onMessage: async (raw) => { - try { - const payload = parseGatewayPayload(raw); - await this.handleGatewayPayload(payload, shouldResume, requestReconnect); - } catch (error) { - const normalized = error instanceof DiscordGatewaySessionError - ? error - : new DiscordGatewaySessionError("malformed_gateway_payload", "Discord sent a malformed Gateway payload.", true); - await requestReconnect({ reconnect: true, resumable: this.state !== null, error: normalized }, 4002, "Malformed payload"); - } - }, - onClose: (code) => settle(closeDecision(code, this.state !== null)), - onError: () => requestReconnect({ - reconnect: true, - resumable: this.state !== null, - error: new DiscordGatewaySessionError("transport_failure", "Discord Gateway transport failed.", true), - }, 4000, "Transport failure"), - }, this.controller.signal); - if (this.controller.signal.aborted) { - await connection.close(1000, "Code UX shutdown"); - return { reconnect: false, resumable: false }; - } - this.currentConnection = connection; - return await completed; - } catch (error) { - if (this.controller.signal.aborted) return { reconnect: false, resumable: false }; - return { - reconnect: true, - resumable: this.state !== null, - error: error instanceof DiscordGatewaySessionError - ? error - : new DiscordGatewaySessionError("transport_failure", "Discord Gateway connection failed.", true), - }; - } finally { - this.controller.signal.removeEventListener("abort", abort); - this.clearHeartbeat(); - this.currentConnection = null; - } - } - - private async handleGatewayPayload( - payload: GatewayPayload, - shouldResume: boolean, - requestReconnect: (decision: ReconnectDecision, code?: number, reason?: string) => Promise, - ): Promise { - if (typeof payload.s === "number") { - if (this.state) { - this.state = { ...this.state, sequence: payload.s }; - await this.persistState(); - } - } - switch (payload.op) { - case 0: - await this.handleDispatch(payload); - return; - case 1: - await this.sendHeartbeat(); - return; - case 7: - await requestReconnect({ reconnect: true, resumable: this.state !== null }); - return; - case 9: { - const resumable = payload.d === true && this.state !== null; - await requestReconnect({ reconnect: true, resumable }, 4000, "Invalid session"); - return; - } - case 10: - await this.handleHello(payload, shouldResume, requestReconnect); - return; - case 11: - this.heartbeatAcknowledged = true; - return; - default: - return; - } - } - - private async handleHello( - payload: GatewayPayload, - shouldResume: boolean, - requestReconnect: (decision: ReconnectDecision, code?: number, reason?: string) => Promise, - ): Promise { - const data = asRecord(payload.d); - const interval = Number(data?.heartbeat_interval); - if (!Number.isFinite(interval) || interval <= 0) { - throw new DiscordGatewaySessionError("malformed_gateway_payload", "Discord Hello omitted a heartbeat interval.", true); - } - this.heartbeatIntervalMs = interval; - this.heartbeatAcknowledged = true; - this.scheduleHeartbeat(Math.floor(interval * clampJitter(this.random())) , requestReconnect); - if (shouldResume && this.state) { - await this.send({ - op: 6, - d: { token: this.options.botToken, session_id: this.state.sessionId, seq: this.state.sequence }, - }); - return; - } - await this.send({ - op: 2, - d: { - token: this.options.botToken, - intents: this.options.intents, - properties: { os: process.platform, browser: "codeux", device: "codeux" }, - }, - }); - } - - private async handleDispatch(payload: GatewayPayload): Promise { - const sequence = typeof payload.s === "number" ? payload.s : this.state?.sequence; - if (payload.t === "READY") { - const data = asRecord(payload.d); - const sessionId = readNonEmptyString(data?.session_id); - const resumeGatewayUrl = normalizeGatewayUrl(readNonEmptyString(data?.resume_gateway_url)); - const user = asRecord(data?.user); - const botUserId = readNonEmptyString(user?.id); - if (!sessionId || !resumeGatewayUrl || sequence === undefined) { - throw new DiscordGatewaySessionError("malformed_gateway_payload", "Discord Ready omitted resumable session state.", true); - } - this.state = { - sessionId, - resumeGatewayUrl, - sequence, - ...(botUserId ? { botUserId } : {}), - }; - this.reconnectAttempt = 0; - await this.persistState(); - return; - } - if (payload.t === "RESUMED") { - this.reconnectAttempt = 0; - await this.persistState(); - return; - } - if (payload.t === "MESSAGE_CREATE") { - const event = normalizeDiscordGatewayEvent( - { op: payload.op, d: payload.d, s: payload.s, t: payload.t }, - this.state?.botUserId, - ); - if (event.kind === "message") await this.options.onMessage(event); - } - } - - private scheduleHeartbeat( - delayMs: number, - requestReconnect: (decision: ReconnectDecision, code?: number, reason?: string) => Promise, - ): void { - this.clearHeartbeat(); - this.heartbeatTimer = this.setTimer(() => { - this.heartbeatTimer = null; - if (!this.heartbeatAcknowledged) { - void requestReconnect({ reconnect: true, resumable: this.state !== null }, 4000, "Missed heartbeat ACK"); - return; - } - void this.sendHeartbeat().then(() => { - this.scheduleHeartbeat(this.heartbeatIntervalMs, requestReconnect); - }).catch(() => { - void requestReconnect({ reconnect: true, resumable: this.state !== null }, 4000, "Heartbeat failed"); - }); - }, Math.max(0, delayMs)); - } - - private async sendHeartbeat(): Promise { - await this.send({ op: 1, d: this.state?.sequence ?? null }); - this.heartbeatAcknowledged = false; - } - - private async send(payload: Record): Promise { - const connection = this.currentConnection; - if (!connection) throw new DiscordGatewaySessionError("transport_failure", "Discord Gateway connection is unavailable.", true); - await connection.send(JSON.stringify(payload)); - } - - private clearHeartbeat(): void { - if (this.heartbeatTimer !== null) this.clearTimer(this.heartbeatTimer); - this.heartbeatTimer = null; - } - - private async persistState(): Promise { - if (this.state) await this.options.sessionStore.save(this.options.connectionId, { ...this.state }); - } - - private async reportFailure(error: DiscordGatewaySessionError): Promise { - await this.options.onFailure?.(error); - } -} - -function parseGatewayPayload(raw: string): GatewayPayload { - const value = JSON.parse(raw) as unknown; - const record = asRecord(value); - if (!record || !Number.isInteger(record.op)) throw new Error("Malformed payload"); - return { - op: Number(record.op), - d: record.d, - s: typeof record.s === "number" ? record.s : null, - t: typeof record.t === "string" ? record.t : null, - }; -} - -function closeDecision(code: number | undefined, hasState: boolean): ReconnectDecision { - if (code === 1000 || code === 1001) return { reconnect: false, resumable: false }; - if (code === 4004) return fatalClose("invalid_auth", "Discord rejected Gateway authentication."); - if (code === 4013) return fatalClose("invalid_intents", "Discord rejected the Gateway intents bitfield."); - if (code === 4014) return fatalClose("missing_privileged_intent", "Discord MESSAGE_CONTENT intent is not enabled or approved."); - if (code === 4010) return fatalClose("invalid_shard", "Discord rejected the Gateway shard configuration."); - if (code === 4011) return fatalClose("sharding_required", "Discord requires Gateway sharding for this bot."); - if (code === 4012) return fatalClose("invalid_api_version", "Discord rejected Gateway API version 10."); - return { reconnect: true, resumable: hasState && !NON_RESUMABLE_CLOSE_CODES.has(code ?? -1) }; -} - -function fatalClose(code: DiscordGatewayFailureCode, message: string): ReconnectDecision { - return { reconnect: true, resumable: false, error: new DiscordGatewaySessionError(code, message, false) }; -} - -function sanitizeSessionState(value: DiscordGatewaySessionState | null): DiscordGatewaySessionState | null { - if (!value) return null; - const resumeGatewayUrl = normalizeGatewayUrl(value.resumeGatewayUrl); - if (!readNonEmptyString(value.sessionId) || !resumeGatewayUrl || !Number.isSafeInteger(value.sequence) || value.sequence < 0) return null; - return { - sessionId: value.sessionId, - resumeGatewayUrl, - sequence: value.sequence, - ...(readNonEmptyString(value.botUserId) ? { botUserId: value.botUserId } : {}), - }; -} - -function normalizeGatewayUrl(value: unknown): string | null { - if (typeof value !== "string" || !value.trim()) return null; - try { - const url = new URL(value); - if (url.protocol !== "wss:" || url.username || url.password) return null; - if (url.hostname !== "gateway.discord.gg" && !url.hostname.endsWith(".discord.gg")) return null; - url.searchParams.set("v", "10"); - url.searchParams.set("encoding", "json"); - return url.toString(); - } catch { - return null; - } -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; -} - -function readNonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function clampJitter(value: number): number { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(1, value)); -} - -function abortableWait(delayMs: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal.aborted) { - reject(signal.reason); - return; - } - const timer = setTimeout(resolve, delayMs); - timer.unref?.(); - signal.addEventListener("abort", () => { - clearTimeout(timer); - reject(signal.reason); - }, { once: true }); - }); -} - diff --git a/src/services/chat-providers/microsoft-bot-auth.ts b/src/services/chat-providers/microsoft-bot-auth.ts deleted file mode 100644 index 2866810fa1..0000000000 --- a/src/services/chat-providers/microsoft-bot-auth.ts +++ /dev/null @@ -1,905 +0,0 @@ -import { createHash, createPublicKey, verify as verifySignature } from "node:crypto"; -import { - isDocumentedMicrosoftBotServiceUrl, - normalizeMicrosoftTeamsActivity, - type MicrosoftBotApplicationType, - type MicrosoftTeamsChannelAccount, - type MicrosoftTeamsConversationAccount, - type MicrosoftTeamsConversationReference, - type NormalizedMicrosoftTeamsActivity, -} from "../../domain/chat-connectors/providers/microsoft-teams.js"; - -export const MICROSOFT_BOT_OPENID_METADATA_URL = "https://login.botframework.com/v1/.well-known/openidconfiguration"; -export const MICROSOFT_BOT_JWKS_URL = "https://login.botframework.com/v1/.well-known/keys"; -export const MICROSOFT_BOT_ISSUER = "https://api.botframework.com"; -export const MICROSOFT_BOT_TOKEN_SCOPE = "https://api.botframework.com/.default"; -export const MICROSOFT_BOT_MAX_SIGNING_KEY_CACHE_MS = 24 * 60 * 60 * 1000; - -const DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000; -const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; -const DEFAULT_TOKEN_PRE_EXPIRY_MS = 5 * 60 * 1000; -const DEFAULT_UNKNOWN_KEY_REFRESH_INTERVAL_MS = 5 * 60 * 1000; -const MAX_JWT_LENGTH = 64 * 1024; - -export type MicrosoftBotAuthErrorCode = - | "app_identity_invalid" - | "authorization_header_invalid" - | "jwt_malformed" - | "jwt_algorithm_invalid" - | "jwt_issuer_invalid" - | "jwt_audience_invalid" - | "jwt_not_yet_valid" - | "jwt_expired" - | "jwt_signature_invalid" - | "signing_key_unknown" - | "signing_key_expired" - | "signing_keys_unusable" - | "channel_endorsement_missing" - | "service_url_invalid" - | "service_url_mismatch" - | "tenant_mismatch" - | "openid_metadata_failed" - | "jwks_failed" - | "token_acquisition_failed" - | "microsoft_throttled" - | "microsoft_service_unavailable" - | "reply_rejected"; - -export interface MicrosoftBotCredentials { - microsoftAppId: string; - applicationType: MicrosoftBotApplicationType; - tenantId?: string; - clientSecret: string; -} - -export interface MicrosoftBotAuthenticatedActivity { - activity: Record; - normalized: NormalizedMicrosoftTeamsActivity; - conversationReference: MicrosoftTeamsConversationReference; - ingressPayload: Record; -} - -export interface MicrosoftBotReplyResult { - externalMessageId: string | null; - statusCode: number; -} - -export interface MicrosoftBotDiagnostic { - check: "app_identity" | "token_acquisition" | "signing_metadata"; - ok: boolean; - code: "ok" | MicrosoftBotAuthErrorCode; - message: string; - retryable: boolean; -} - -export interface MicrosoftBotConnectionDiagnostics { - ok: boolean; - checks: readonly MicrosoftBotDiagnostic[]; -} - -export class MicrosoftBotAuthError extends Error { - constructor( - readonly code: MicrosoftBotAuthErrorCode, - message: string, - readonly statusCode: number, - readonly retryable = false, - readonly retryAfterMs: number | null = null, - ) { - super(message); - this.name = "MicrosoftBotAuthError"; - } -} - -export interface MicrosoftBotAuthServiceOptions { - fetch?: typeof fetch; - now?: () => Date; - signingKeyCacheMs?: number; - clockSkewMs?: number; - requestTimeoutMs?: number; - tokenPreExpiryMs?: number; - unknownKeyRefreshIntervalMs?: number; -} - -export interface ValidateIncomingActivityInput { - authorization: string | undefined; - activity: unknown; - credentials: MicrosoftBotCredentials; -} - -export interface SendReplyInput { - credentials: MicrosoftBotCredentials; - conversationReference: MicrosoftTeamsConversationReference; - text: string; - correlationId?: string; -} - -interface JwtHeader { - alg: string; - kid: string; -} - -interface JwtClaims { - iss: unknown; - aud: unknown; - nbf: unknown; - exp: unknown; - iat?: unknown; - serviceUrl: unknown; -} - -interface BotOpenIdMetadata { - issuer: string; - jwks_uri: string; - id_token_signing_alg_values_supported: string[]; -} - -interface BotSigningKey extends Record { - kid: string; - kty: string; - alg?: string; - use?: string; - key_ops?: string[]; - endorsements: string[]; -} - -interface SigningKeyCache { - fetchedAt: number; - expiresAt: number; - keys: BotSigningKey[]; -} - -interface AccessTokenCache { - accessToken: string; - usableUntil: number; -} - -interface HttpOperation { - kind: "metadata" | "jwks" | "token" | "reply"; - failureCode: MicrosoftBotAuthErrorCode; - label: string; -} - -const OPERATIONS = { - metadata: { kind: "metadata", failureCode: "openid_metadata_failed", label: "OpenID metadata" }, - jwks: { kind: "jwks", failureCode: "jwks_failed", label: "signing keys" }, - token: { kind: "token", failureCode: "token_acquisition_failed", label: "OAuth token" }, - reply: { kind: "reply", failureCode: "reply_rejected", label: "Bot Connector reply" }, -} as const satisfies Record; - -export class MicrosoftBotAuthService { - private readonly fetchImpl: typeof fetch; - private readonly now: () => Date; - private readonly signingKeyCacheMs: number; - private readonly clockSkewMs: number; - private readonly requestTimeoutMs: number; - private readonly tokenPreExpiryMs: number; - private readonly unknownKeyRefreshIntervalMs: number; - private signingKeyCache: SigningKeyCache | null = null; - private signingKeyRefreshPromise: Promise | null = null; - private lastUnknownKeyRefreshAt = Number.NEGATIVE_INFINITY; - private readonly tokenCache = new Map(); - private readonly tokenRequests = new Map>(); - - constructor(options: MicrosoftBotAuthServiceOptions = {}) { - this.fetchImpl = options.fetch ?? fetch; - this.now = options.now ?? (() => new Date()); - this.signingKeyCacheMs = clampPositive( - options.signingKeyCacheMs ?? MICROSOFT_BOT_MAX_SIGNING_KEY_CACHE_MS, - MICROSOFT_BOT_MAX_SIGNING_KEY_CACHE_MS, - ); - this.clockSkewMs = clampNonNegative(options.clockSkewMs ?? DEFAULT_CLOCK_SKEW_MS); - this.requestTimeoutMs = clampPositive(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, 60_000); - this.tokenPreExpiryMs = clampNonNegative(options.tokenPreExpiryMs ?? DEFAULT_TOKEN_PRE_EXPIRY_MS); - this.unknownKeyRefreshIntervalMs = clampNonNegative( - options.unknownKeyRefreshIntervalMs ?? DEFAULT_UNKNOWN_KEY_REFRESH_INTERVAL_MS, - ); - } - - async validateIncomingActivity(input: ValidateIncomingActivityInput): Promise { - const credentials = validateCredentials(input.credentials); - const activity = requireRecord(input.activity, "Activity"); - const token = parseBearerToken(input.authorization); - const compactJwt = parseCompactJwt(token); - if (compactJwt.header.alg !== "RS256") { - throw authError("jwt_algorithm_invalid", "Microsoft Bot Connector JWT must use RS256."); - } - - let keys = (await this.getSigningKeyCache(false)).keys; - let key = keys.find((candidate) => candidate.kid === compactJwt.header.kid); - if (!key && this.canRefreshForUnknownKey()) { - this.lastUnknownKeyRefreshAt = this.nowMs(); - keys = (await this.getSigningKeyCache(true)).keys; - key = keys.find((candidate) => candidate.kid === compactJwt.header.kid); - } - if (!key) { - throw authError("signing_key_unknown", "Microsoft Bot Connector JWT uses an unknown signing key ID."); - } - - this.validateSigningKey(key); - this.verifyJwtSignature(compactJwt.signingInput, compactJwt.signature, key); - this.validateClaims(compactJwt.claims, credentials, activity); - this.validateChannelEndorsement(key, activity); - const conversationReference = buildMicrosoftTeamsConversationReference(activity); - this.validateTenant(credentials, conversationReference.tenantId); - const normalized = normalizeMicrosoftTeamsActivity(activity, { requireType: true }); - const ingressPayload = { - ...activity, - microsoftTeamsConversationReference: conversationReference, - }; - return { activity, normalized, conversationReference, ingressPayload }; - } - - async acquireAccessToken(credentialsInput: MicrosoftBotCredentials): Promise { - const credentials = validateCredentials(credentialsInput); - const cacheKey = tokenCacheKey(credentials); - const cached = this.tokenCache.get(cacheKey); - if (cached && cached.usableUntil > this.nowMs()) { - return cached.accessToken; - } - - const pending = this.tokenRequests.get(cacheKey); - if (pending) { - return pending; - } - const request = this.requestAccessToken(credentials, cacheKey).finally(() => { - this.tokenRequests.delete(cacheKey); - }); - this.tokenRequests.set(cacheKey, request); - return request; - } - - async sendReply(input: SendReplyInput): Promise { - const credentials = validateCredentials(input.credentials); - const reference = validateConversationReference(input.conversationReference); - this.validateTenant(credentials, reference.tenantId); - const accessToken = await this.acquireAccessToken(credentials); - const replyUrl = buildReplyUrl(reference); - const response = await this.fetchWithTimeout(replyUrl, { - method: "POST", - headers: { - authorization: `Bearer ${accessToken}`, - "content-type": "application/json", - ...(input.correlationId ? { "x-correlation-id": input.correlationId } : {}), - }, - body: JSON.stringify(buildReplyActivity(reference, input.text)), - }, OPERATIONS.reply); - if (!response.ok) { - throw this.httpFailure(OPERATIONS.reply, response.status, response.headers); - } - const responseBody = await response.text().catch(() => ""); - const parsed = parseOptionalRecord(responseBody); - return { - externalMessageId: readRequiredString(parsed?.id) ?? null, - statusCode: response.status, - }; - } - - async diagnoseConnection(credentialsInput: MicrosoftBotCredentials): Promise { - const checks: MicrosoftBotDiagnostic[] = []; - try { - validateCredentials(credentialsInput); - checks.push(okDiagnostic("app_identity", "Microsoft app identity configuration is valid.")); - } catch (error) { - checks.push(errorDiagnostic("app_identity", error)); - return { ok: false, checks }; - } - - try { - await this.acquireAccessToken(credentialsInput); - checks.push(okDiagnostic("token_acquisition", "Microsoft Bot Connector OAuth token acquisition succeeded.")); - } catch (error) { - checks.push(errorDiagnostic("token_acquisition", error)); - } - - try { - const signingKeyCache = await this.getSigningKeyCache(true); - this.validateDiagnosticSigningKeys(signingKeyCache.keys); - checks.push(okDiagnostic("signing_metadata", "Microsoft Bot Connector OpenID metadata and signing keys are available.")); - } catch (error) { - checks.push(errorDiagnostic("signing_metadata", error)); - } - - return { ok: checks.every((check) => check.ok), checks }; - } - - private async requestAccessToken(credentials: MicrosoftBotCredentials, cacheKey: string): Promise { - const tenant = credentials.applicationType === "MultiTenant" ? "botframework.com" : credentials.tenantId!; - const tokenUrl = `https://login.microsoftonline.com/${encodeURIComponent(tenant)}/oauth2/v2.0/token`; - const form = new URLSearchParams({ - grant_type: "client_credentials", - client_id: credentials.microsoftAppId, - client_secret: credentials.clientSecret, - scope: MICROSOFT_BOT_TOKEN_SCOPE, - }); - const response = await this.fetchWithTimeout(tokenUrl, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: form.toString(), - }, OPERATIONS.token); - if (!response.ok) { - throw this.httpFailure(OPERATIONS.token, response.status, response.headers); - } - const body = await readJsonRecord(response, OPERATIONS.token); - const accessToken = readRequiredString(body.access_token); - const tokenType = readRequiredString(body.token_type); - const expiresIn = readPositiveNumber(body.expires_in); - if (!accessToken || tokenType?.toLowerCase() !== "bearer" || !expiresIn) { - throw new MicrosoftBotAuthError( - "token_acquisition_failed", - "Microsoft OAuth token response is missing a bearer access token or expiry.", - 502, - true, - ); - } - const ttlMs = expiresIn * 1000; - const preExpiryMs = Math.min(this.tokenPreExpiryMs, Math.floor(ttlMs / 2)); - this.tokenCache.set(cacheKey, { - accessToken, - usableUntil: this.nowMs() + Math.max(0, ttlMs - preExpiryMs), - }); - return accessToken; - } - - private async getSigningKeyCache(forceRefresh: boolean): Promise { - if (!forceRefresh && this.isSigningKeyCacheFresh()) { - return this.signingKeyCache!; - } - if (this.signingKeyRefreshPromise) { - return this.signingKeyRefreshPromise; - } - const refresh = this.refreshSigningKeys().finally(() => { - if (this.signingKeyRefreshPromise === refresh) { - this.signingKeyRefreshPromise = null; - } - }); - this.signingKeyRefreshPromise = refresh; - return refresh; - } - - private async refreshSigningKeys(): Promise { - const metadataResponse = await this.fetchWithTimeout(MICROSOFT_BOT_OPENID_METADATA_URL, {}, OPERATIONS.metadata); - if (!metadataResponse.ok) { - throw this.httpFailure(OPERATIONS.metadata, metadataResponse.status, metadataResponse.headers); - } - const metadataBody = await readJsonRecord(metadataResponse, OPERATIONS.metadata); - const metadata = parseOpenIdMetadata(metadataBody); - const keysResponse = await this.fetchWithTimeout(metadata.jwks_uri, {}, OPERATIONS.jwks); - if (!keysResponse.ok) { - throw this.httpFailure(OPERATIONS.jwks, keysResponse.status, keysResponse.headers); - } - const keysBody = await readJsonRecord(keysResponse, OPERATIONS.jwks); - const keys = parseSigningKeys(keysBody); - const fetchedAt = this.nowMs(); - const cache = { fetchedAt, expiresAt: fetchedAt + this.signingKeyCacheMs, keys }; - this.signingKeyCache = cache; - return cache; - } - - private validateSigningKey(key: BotSigningKey): void { - if (key.kty !== "RSA" || (key.alg && key.alg !== "RS256") || (key.use && key.use !== "sig")) { - throw authError("jwt_algorithm_invalid", "Microsoft signing key is not valid for RS256 signatures."); - } - if (key.key_ops && !key.key_ops.includes("verify")) { - throw authError("jwt_algorithm_invalid", "Microsoft signing key is not endorsed for signature verification."); - } - const now = this.nowMs(); - const notBefore = readTimestampMs(key.nbf); - const expiresAt = readTimestampMs(key.exp); - if (notBefore !== null && now + this.clockSkewMs < notBefore) { - throw new MicrosoftBotAuthError("signing_key_expired", "Microsoft signing key is not active yet.", 401); - } - if (expiresAt !== null && now - this.clockSkewMs >= expiresAt) { - throw new MicrosoftBotAuthError("signing_key_expired", "Microsoft signing key has expired.", 401); - } - } - - private validateDiagnosticSigningKeys(keys: readonly BotSigningKey[]): void { - const failures: MicrosoftBotAuthError[] = []; - for (const key of keys) { - try { - this.validateSigningKey(key); - if (!key.endorsements.includes("msteams")) { - throw new MicrosoftBotAuthError( - "channel_endorsement_missing", - "Microsoft signing key does not endorse Microsoft Teams.", - 502, - ); - } - createPublicKey({ key: key as JsonWebKey, format: "jwk" }); - return; - } catch (error) { - failures.push(error instanceof MicrosoftBotAuthError - ? error - : new MicrosoftBotAuthError( - "signing_keys_unusable", - "Microsoft signing key material is not usable for signature verification.", - 502, - )); - } - } - - const allExpired = failures.length > 0 && failures.every((failure) => failure.code === "signing_key_expired"); - throw new MicrosoftBotAuthError( - allExpired ? "signing_key_expired" : "signing_keys_unusable", - allExpired - ? "Microsoft Bot Connector published no currently active signing keys." - : "Microsoft Bot Connector published no usable Microsoft Teams signing keys.", - 502, - true, - ); - } - - private verifyJwtSignature(signingInput: string, signature: Buffer, key: BotSigningKey): void { - try { - const publicKey = createPublicKey({ key: key as JsonWebKey, format: "jwk" }); - if (!verifySignature("RSA-SHA256", Buffer.from(signingInput), publicKey, signature)) { - throw authError("jwt_signature_invalid", "Microsoft Bot Connector JWT signature is invalid."); - } - } catch (error) { - if (error instanceof MicrosoftBotAuthError) { - throw error; - } - throw authError("jwt_signature_invalid", "Microsoft Bot Connector JWT signing key or signature is invalid."); - } - } - - private validateClaims( - claims: JwtClaims, - credentials: MicrosoftBotCredentials, - activity: Record, - ): void { - if (claims.iss !== MICROSOFT_BOT_ISSUER) { - throw authError("jwt_issuer_invalid", "Microsoft Bot Connector JWT issuer is invalid."); - } - const audiences = typeof claims.aud === "string" - ? [claims.aud] - : Array.isArray(claims.aud) ? claims.aud.filter((value): value is string => typeof value === "string") : []; - if (!audiences.includes(credentials.microsoftAppId)) { - throw authError("jwt_audience_invalid", "Microsoft Bot Connector JWT audience does not match the Microsoft app ID."); - } - const now = this.nowMs(); - const notBefore = readJwtNumericDateMs(claims.nbf); - const expiresAt = readJwtNumericDateMs(claims.exp); - if (notBefore === null || now + this.clockSkewMs < notBefore) { - throw authError("jwt_not_yet_valid", "Microsoft Bot Connector JWT is outside its validity window."); - } - if (expiresAt === null || now - this.clockSkewMs >= expiresAt) { - throw authError("jwt_expired", "Microsoft Bot Connector JWT has expired."); - } - const issuedAt = claims.iat === undefined ? null : readJwtNumericDateMs(claims.iat); - if (claims.iat !== undefined && issuedAt === null) { - throw authError("jwt_not_yet_valid", "Microsoft Bot Connector JWT issued-at claim is invalid."); - } - if (issuedAt !== null && now + this.clockSkewMs < issuedAt) { - throw authError("jwt_not_yet_valid", "Microsoft Bot Connector JWT was issued in the future."); - } - const activityServiceUrl = readRequiredString(activity.serviceUrl); - const claimServiceUrl = readRequiredString(claims.serviceUrl); - if (!activityServiceUrl || !claimServiceUrl || activityServiceUrl !== claimServiceUrl) { - throw authError("service_url_mismatch", "JWT serviceUrl claim does not exactly match the Activity serviceUrl."); - } - requireAllowedMicrosoftBotServiceUrl(activityServiceUrl); - } - - private validateChannelEndorsement(key: BotSigningKey, activity: Record): void { - const channelId = readRequiredString(activity.channelId); - if (!channelId || !key.endorsements.includes(channelId)) { - throw new MicrosoftBotAuthError( - "channel_endorsement_missing", - "Microsoft signing key does not endorse the Activity channel.", - 403, - ); - } - } - - private validateTenant(credentials: MicrosoftBotCredentials, activityTenantId: string | undefined): void { - if (credentials.tenantId && credentials.tenantId !== activityTenantId) { - throw new MicrosoftBotAuthError( - "tenant_mismatch", - "Microsoft Teams Activity tenant does not match the configured tenant.", - 403, - ); - } - if (credentials.applicationType === "SingleTenant" && !activityTenantId) { - throw new MicrosoftBotAuthError( - "tenant_mismatch", - "Single-tenant Microsoft Teams Activities must include the configured tenant.", - 403, - ); - } - } - - private async fetchWithTimeout( - url: string, - init: RequestInit, - operation: HttpOperation, - ): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); - try { - return await this.fetchImpl(url, { ...init, signal: controller.signal }); - } catch (error) { - const detail = controller.signal.aborted ? "timed out" : "is unavailable"; - throw new MicrosoftBotAuthError( - "microsoft_service_unavailable", - `Microsoft ${operation.label} service ${detail}.`, - 503, - true, - ); - } finally { - clearTimeout(timeout); - } - } - - private httpFailure(operation: HttpOperation, status: number, headers: Headers): MicrosoftBotAuthError { - if (status === 429) { - return new MicrosoftBotAuthError( - "microsoft_throttled", - `Microsoft ${operation.label} service throttled the request.`, - 429, - true, - parseRetryAfterMs(headers.get("retry-after"), this.nowMs()), - ); - } - if (status >= 500 || status === 408) { - return new MicrosoftBotAuthError( - "microsoft_service_unavailable", - `Microsoft ${operation.label} service is unavailable (HTTP ${status}).`, - status, - true, - ); - } - return new MicrosoftBotAuthError( - operation.failureCode, - `Microsoft ${operation.label} request failed (HTTP ${status}).`, - status, - operation.kind === "reply" && (status === 409 || status === 425), - ); - } - - private isSigningKeyCacheFresh(): boolean { - return this.signingKeyCache !== null && this.signingKeyCache.expiresAt > this.nowMs(); - } - - private canRefreshForUnknownKey(): boolean { - return this.nowMs() - this.lastUnknownKeyRefreshAt >= this.unknownKeyRefreshIntervalMs; - } - - private nowMs(): number { - return this.now().getTime(); - } -} - -export function isAllowedMicrosoftBotServiceUrl(value: string): boolean { - return isDocumentedMicrosoftBotServiceUrl(value); -} - -export function requireAllowedMicrosoftBotServiceUrl(value: string): string { - if (!isAllowedMicrosoftBotServiceUrl(value)) { - throw new MicrosoftBotAuthError( - "service_url_invalid", - "Microsoft Teams service URL must use HTTPS on a documented Bot Framework host.", - 403, - ); - } - return value; -} - -function buildMicrosoftTeamsConversationReference( - activity: Record, -): MicrosoftTeamsConversationReference { - const serviceUrl = requireAllowedMicrosoftBotServiceUrl(requireString(activity.serviceUrl, "Activity serviceUrl")); - const conversation = requireAccount(activity.conversation, "Activity conversation"); - const bot = requireAccount(activity.recipient, "Activity recipient"); - const user = requireAccount(activity.from, "Activity sender"); - const channelData = optionalRecord(activity.channelData); - const tenant = optionalRecord(channelData?.tenant); - const team = optionalRecord(channelData?.team); - const channel = optionalRecord(channelData?.channel); - return { - activityId: requireString(activity.id, "Activity ID"), - serviceUrl, - serviceUrlValidated: true, - channelId: requireString(activity.channelId, "Activity channel ID"), - locale: readRequiredString(activity.locale), - tenantId: readRequiredString(tenant?.id), - teamId: readRequiredString(team?.id), - teamsChannelId: readRequiredString(channel?.id), - conversation: conversation as MicrosoftTeamsConversationAccount, - bot, - user, - }; -} - -function validateCredentials(credentials: MicrosoftBotCredentials): MicrosoftBotCredentials { - if (!credentials || typeof credentials !== "object") { - throw new MicrosoftBotAuthError("app_identity_invalid", "Microsoft bot credentials are required.", 400); - } - if (!readRequiredString(credentials.microsoftAppId)) { - throw new MicrosoftBotAuthError("app_identity_invalid", "Microsoft app ID is required.", 400); - } - if (credentials.applicationType !== "MultiTenant" && credentials.applicationType !== "SingleTenant") { - throw new MicrosoftBotAuthError("app_identity_invalid", "Microsoft application type is invalid.", 400); - } - if (credentials.applicationType === "SingleTenant" && !readRequiredString(credentials.tenantId)) { - throw new MicrosoftBotAuthError("app_identity_invalid", "Single-tenant Microsoft bots require a tenant ID.", 400); - } - if (!readRequiredString(credentials.clientSecret)) { - throw new MicrosoftBotAuthError("app_identity_invalid", "Microsoft client secret is required.", 400); - } - return { - microsoftAppId: credentials.microsoftAppId.trim(), - applicationType: credentials.applicationType, - tenantId: credentials.tenantId?.trim() || undefined, - clientSecret: credentials.clientSecret, - }; -} - -function parseBearerToken(authorization: string | undefined): string { - const match = authorization?.match(/^Bearer ([^\s]+)$/i); - if (!match?.[1]) { - throw authError("authorization_header_invalid", "Microsoft Bot Connector request requires a Bearer authorization header."); - } - return match[1]; -} - -function parseCompactJwt(token: string): { - header: JwtHeader; - claims: JwtClaims; - signingInput: string; - signature: Buffer; -} { - if (token.length > MAX_JWT_LENGTH) { - throw authError("jwt_malformed", "Microsoft Bot Connector JWT is too large."); - } - const parts = token.split("."); - if (parts.length !== 3 || parts.some((part) => !part)) { - throw authError("jwt_malformed", "Microsoft Bot Connector JWT is malformed."); - } - try { - const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")) as unknown; - const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as unknown; - const headerRecord = requireRecord(header, "JWT header"); - const claimsRecord = requireRecord(claims, "JWT claims"); - const alg = readRequiredString(headerRecord.alg); - const kid = readRequiredString(headerRecord.kid); - if (!alg || !kid) { - throw new Error("missing alg or kid"); - } - return { - header: { alg, kid }, - claims: claimsRecord as unknown as JwtClaims, - signingInput: `${parts[0]}.${parts[1]}`, - signature: Buffer.from(parts[2], "base64url"), - }; - } catch (error) { - if (error instanceof MicrosoftBotAuthError) { - throw error; - } - throw authError("jwt_malformed", "Microsoft Bot Connector JWT header or claims are malformed."); - } -} - -function parseOpenIdMetadata(body: Record): BotOpenIdMetadata { - const issuer = readRequiredString(body.issuer); - const jwksUri = readRequiredString(body.jwks_uri); - const algorithms = Array.isArray(body.id_token_signing_alg_values_supported) - ? body.id_token_signing_alg_values_supported.filter((value): value is string => typeof value === "string") - : []; - if (issuer !== MICROSOFT_BOT_ISSUER || jwksUri !== MICROSOFT_BOT_JWKS_URL || !algorithms.includes("RS256")) { - throw new MicrosoftBotAuthError( - "openid_metadata_failed", - "Microsoft Bot Connector OpenID metadata does not match the fixed issuer, JWKS URL, and RS256 contract.", - 502, - true, - ); - } - return { issuer, jwks_uri: jwksUri, id_token_signing_alg_values_supported: algorithms }; -} - -function parseSigningKeys(body: Record): BotSigningKey[] { - if (!Array.isArray(body.keys)) { - throw new MicrosoftBotAuthError("jwks_failed", "Microsoft signing keys response is malformed.", 502, true); - } - const keys = body.keys.map((value) => { - const key = optionalRecord(value); - if (!key) { - throw new MicrosoftBotAuthError("jwks_failed", "Microsoft signing key must be an object.", 502, true); - } - const kid = readRequiredString(key.kid); - const kty = readRequiredString(key.kty); - if (!kid || !kty) { - throw new MicrosoftBotAuthError("jwks_failed", "Microsoft signing key is missing kid or kty.", 502, true); - } - return { - ...key, - kid, - kty, - alg: readRequiredString(key.alg), - use: readRequiredString(key.use), - key_ops: Array.isArray(key.key_ops) ? key.key_ops.filter((entry): entry is string => typeof entry === "string") : undefined, - endorsements: Array.isArray(key.endorsements) - ? key.endorsements.filter((entry): entry is string => typeof entry === "string") - : [], - }; - }); - if (keys.length === 0) { - throw new MicrosoftBotAuthError("jwks_failed", "Microsoft signing keys response is empty.", 502, true); - } - return keys; -} - -function validateConversationReference(reference: MicrosoftTeamsConversationReference): MicrosoftTeamsConversationReference { - if (!reference || reference.serviceUrlValidated !== true) { - throw new MicrosoftBotAuthError( - "service_url_invalid", - "Microsoft Teams reply requires an authenticated persisted conversation reference.", - 403, - ); - } - requireAllowedMicrosoftBotServiceUrl(reference.serviceUrl); - requireString(reference.activityId, "conversation reference activity ID"); - requireString(reference.channelId, "conversation reference channel ID"); - requireString(reference.conversation?.id, "conversation reference conversation ID"); - requireString(reference.bot?.id, "conversation reference bot ID"); - requireString(reference.user?.id, "conversation reference user ID"); - return reference; -} - -function buildReplyUrl(reference: MicrosoftTeamsConversationReference): string { - const url = new URL(reference.serviceUrl); - const basePath = url.pathname.replace(/\/+$/, ""); - url.pathname = `${basePath}/v3/conversations/${encodeURIComponent(reference.conversation.id)}/activities/${encodeURIComponent(reference.activityId)}`; - url.search = ""; - url.hash = ""; - return url.toString(); -} - -function buildReplyActivity(reference: MicrosoftTeamsConversationReference, text: string): Record { - return { - type: "message", - from: reference.bot, - recipient: reference.user, - conversation: reference.conversation, - locale: reference.locale, - replyToId: reference.activityId, - text, - channelData: { - tenant: reference.tenantId ? { id: reference.tenantId } : undefined, - team: reference.teamId ? { id: reference.teamId } : undefined, - channel: reference.teamsChannelId ? { id: reference.teamsChannelId } : undefined, - }, - }; -} - -function requireAccount(value: unknown, label: string): MicrosoftTeamsChannelAccount | MicrosoftTeamsConversationAccount { - const account = requireRecord(value, label); - const id = requireString(account.id, `${label} ID`); - return { - id, - name: readRequiredString(account.name), - aadObjectId: readRequiredString(account.aadObjectId), - conversationType: readRequiredString(account.conversationType), - isGroup: typeof account.isGroup === "boolean" ? account.isGroup : undefined, - }; -} - -function requireRecord(value: unknown, label: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw authError("jwt_malformed", `${label} must be an object.`); - } - return value as Record; -} - -function optionalRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; -} - -function requireString(value: unknown, label: string): string { - const stringValue = readRequiredString(value); - if (!stringValue) { - throw new MicrosoftBotAuthError("service_url_invalid", `${label} is required.`, 403); - } - return stringValue; -} - -function readRequiredString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function readPositiveNumber(value: unknown): number | null { - const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN; - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; -} - -function readTimestampMs(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return Math.abs(value) < 10_000_000_000 ? value * 1000 : value; - } - if (typeof value === "string" && value.trim()) { - const numeric = Number(value); - if (Number.isFinite(numeric)) { - return Math.abs(numeric) < 10_000_000_000 ? numeric * 1000 : numeric; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; - } - return null; -} - -function readJwtNumericDateMs(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value * 1000 : null; -} - -async function readJsonRecord(response: Response, operation: HttpOperation): Promise> { - try { - return requireRecord(await response.json(), `${operation.label} response`); - } catch (error) { - if (error instanceof MicrosoftBotAuthError && error.code !== "jwt_malformed") { - throw error; - } - throw new MicrosoftBotAuthError( - operation.failureCode, - `Microsoft ${operation.label} response is not valid JSON.`, - 502, - true, - ); - } -} - -function parseOptionalRecord(value: string): Record | null { - if (!value.trim()) { - return null; - } - try { - return optionalRecord(JSON.parse(value) as unknown); - } catch { - return null; - } -} - -function tokenCacheKey(credentials: MicrosoftBotCredentials): string { - const secretFingerprint = createHash("sha256").update(credentials.clientSecret).digest("base64url"); - return [credentials.microsoftAppId, credentials.applicationType, credentials.tenantId ?? "", secretFingerprint].join(":"); -} - -function parseRetryAfterMs(value: string | null, nowMs: number): number | null { - if (!value) { - return null; - } - const seconds = Number(value); - if (Number.isFinite(seconds) && seconds >= 0) { - return Math.round(seconds * 1000); - } - const date = Date.parse(value); - return Number.isFinite(date) ? Math.max(0, date - nowMs) : null; -} - -function okDiagnostic(check: MicrosoftBotDiagnostic["check"], message: string): MicrosoftBotDiagnostic { - return { check, ok: true, code: "ok", message, retryable: false }; -} - -function errorDiagnostic(check: MicrosoftBotDiagnostic["check"], error: unknown): MicrosoftBotDiagnostic { - if (error instanceof MicrosoftBotAuthError) { - return { check, ok: false, code: error.code, message: error.message, retryable: error.retryable }; - } - return { - check, - ok: false, - code: "microsoft_service_unavailable", - message: "Microsoft service diagnostic failed.", - retryable: true, - }; -} - -function authError(code: MicrosoftBotAuthErrorCode, message: string): MicrosoftBotAuthError { - return new MicrosoftBotAuthError(code, message, 401); -} - -function clampPositive(value: number, maximum: number): number { - return Math.min(Math.max(1, Number.isFinite(value) ? value : 1), maximum); -} - -function clampNonNegative(value: number): number { - return Math.max(0, Number.isFinite(value) ? value : 0); -} diff --git a/tests/backend/domain/chat-connectors/discord.test.ts b/tests/backend/domain/chat-connectors/discord.test.ts deleted file mode 100644 index bcfff63ce1..0000000000 --- a/tests/backend/domain/chat-connectors/discord.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { createPrivateKey, createPublicKey, sign } from "node:crypto"; -import { describe, expect, it, vi } from "vitest"; -import type { - ChatProviderChannelBindingRecord, - ChatProviderConnectionInternalRecord, - ChatProviderMessageDeliveryRecord, -} from "../../../../src/contracts/chat-provider-types.js"; -import { - DISCORD_API_BASE_URL, - DISCORD_DEFAULT_INTENTS, - DISCORD_MESSAGE_CONTENT_INTENT, - DiscordApiError, - DiscordOfficialApiClient, - discordChatConnectorProfile, - normalizeDiscordGatewayEvent, - stableDiscordNonce, - verifyDiscordInteractionRequest, -} from "../../../../src/domain/chat-connectors/providers/discord.js"; -import type { ChatConnectorOutboundContext } from "../../../../src/domain/chat-connectors/types.js"; - -const PRIVATE_KEY = createPrivateKey({ - key: Buffer.from("302e020100300506032b6570042204209d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "hex"), - format: "der", - type: "pkcs8", -}); -const PUBLIC_KEY = createPublicKey(PRIVATE_KEY).export({ format: "der", type: "spki" }).subarray(-32).toString("hex"); -const NOW = new Date("2026-07-13T12:00:00.000Z"); -const TIMESTAMP = String(Math.floor(NOW.getTime() / 1_000)); - -describe("Discord connector profile", () => { - it("retains the legacy webhook contract and adds official_api setup", () => { - expect(discordChatConnectorProfile.setupSchema.defaultBridgeMode).toBe("webhook"); - expect(discordChatConnectorProfile.supportedTransportModes).toEqual(["webhook", "official_api"]); - expect(discordChatConnectorProfile.setupSchema.bridgeModes).toEqual([ - expect.objectContaining({ - mode: "webhook", - setupFields: expect.arrayContaining([expect.objectContaining({ key: "gatewayUrl" })]), - }), - expect.objectContaining({ - mode: "official_api", - setupFields: expect.arrayContaining([ - expect.objectContaining({ key: "applicationId", required: true }), - expect.objectContaining({ key: "publicKey", required: true }), - expect.objectContaining({ key: "intents", defaultValue: String(DISCORD_DEFAULT_INTENTS) }), - ]), - secretFields: [expect.objectContaining({ key: "botToken", required: true })], - }), - ]); - }); - - it("requires a valid public key, intents bitfield, and privileged MESSAGE_CONTENT intent", () => { - const withoutIntent = discordChatConnectorProfile.verification.verifyConfiguration( - "official_api", - { applicationId: "123", publicKey: PUBLIC_KEY, intents: "513" }, - { botToken: "write-only-token" }, - ); - expect(withoutIntent).toEqual({ - valid: false, - issues: ["Discord MESSAGE_CONTENT intent is required to receive ordinary message text."], - }); - expect(DISCORD_DEFAULT_INTENTS & DISCORD_MESSAGE_CONTENT_INTENT).toBe(DISCORD_MESSAGE_CONTENT_INTENT); - }); - - it("verifies Ed25519 over the exact timestamp and raw body and returns PONG", () => { - const rawBody = '{ "type": 1, "exact": " spacing " }'; - const result = verifyDiscordInteractionRequest(signedRequest(rawBody)); - - expect(result).toEqual(expect.objectContaining({ - ok: true, - kind: "ping", - response: { - statusCode: 200, - headers: { "content-type": "application/json" }, - body: { type: 1 }, - }, - })); - - const reformatted = verifyDiscordInteractionRequest({ - ...signedRequest(rawBody), - rawBody: JSON.stringify(JSON.parse(rawBody)), - }); - expect(reformatted).toMatchObject({ ok: false, code: "signature_mismatch", statusCode: 401 }); - }); - - it("rejects invalid, malformed, and stale interaction authentication deterministically", () => { - const valid = signedRequest('{"type":1}'); - expect(verifyDiscordInteractionRequest({ ...valid, headers: {} })).toMatchObject({ ok: false, code: "missing_signature" }); - expect(verifyDiscordInteractionRequest({ - ...valid, - headers: { ...valid.headers, "X-Signature-Ed25519": "not-hex" }, - })).toMatchObject({ ok: false, code: "malformed_signature" }); - expect(verifyDiscordInteractionRequest({ ...valid, now: new Date("2026-07-13T12:10:00.000Z") })).toMatchObject({ - ok: false, - code: "stale_timestamp", - }); - }); - - it("normalizes supported HTTP interactions into stable message and thread identities", () => { - const rawBody = JSON.stringify({ - id: "111111111111111111", - type: 2, - channel_id: "222222222222222222", - channel: { id: "222222222222222222", name: "triage", type: 11 }, - member: { nick: "Alex", user: { id: "333333333333333333", username: "alex" } }, - data: { name: "ask", options: [{ name: "prompt", value: "repair CI" }] }, - }); - const result = verifyDiscordInteractionRequest(signedRequest(rawBody)); - - expect(result).toMatchObject({ - ok: true, - kind: "message", - normalized: { - externalChannelId: "222222222222222222", - externalChannelName: "triage", - externalSenderId: "333333333333333333", - externalSenderName: "alex", - externalMessageId: "111111111111111111", - externalThreadId: "222222222222222222", - textBody: "repair CI", - }, - }); - }); - - it("normalizes MESSAGE_CREATE and suppresses only the connector bot's own messages", () => { - const payload = { - op: 0, - t: "MESSAGE_CREATE", - s: 42, - d: { - id: "111111111111111111", - channel_id: "222222222222222222", - content: "Investigate the failure", - timestamp: "2026-07-13T12:00:00.000Z", - author: { id: "333333333333333333", username: "alex" }, - }, - }; - expect(normalizeDiscordGatewayEvent(payload, "999999999999999999")).toMatchObject({ - kind: "message", - normalized: { - externalChannelId: "222222222222222222", - externalSenderId: "333333333333333333", - externalMessageId: "111111111111111111", - textBody: "Investigate the failure", - }, - }); - expect(normalizeDiscordGatewayEvent(payload, "333333333333333333")).toEqual({ kind: "ignored", reason: "self_message" }); - }); - - it("keeps legacy gateway URLs but pins official replies to Discord API v10", () => { - const legacy = discordChatConnectorProfile.outbound.buildRequest(outboundContext("webhook")); - expect(legacy).toMatchObject({ url: "https://bridge.example.test/discord", bearerSecretKeys: expect.arrayContaining(["botToken"]) }); - - const official = discordChatConnectorProfile.outbound.buildRequest(outboundContext("official_api")); - expect(official.url).toBe(`${DISCORD_API_BASE_URL}/channels/222222222222222222/messages`); - expect(official.url).not.toContain("bridge.example.test"); - expect(official).toMatchObject({ - headers: { authorization: "Bot discord-test-token" }, - body: { - content: "Fixed the workflow", - allowed_mentions: { parse: [] }, - enforce_nonce: true, - message_reference: { message_id: "111111111111111111", fail_if_not_exists: false }, - }, - }); - expect((official.body as { nonce: string }).nonce).toBe(stableDiscordNonce("delivery-1")); - }); -}); - -describe("Discord official REST client", () => { - it("sends safe idempotent replies and parses returned snowflakes", async () => { - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ id: "444444444444444444" }), { - status: 200, - headers: { "content-type": "application/json" }, - })); - const client = new DiscordOfficialApiClient({ fetch: fetchImpl }); - - await expect(client.sendReply({ - botToken: "secret-token", - channelId: "222222222222222222", - content: "@everyone safe reply", - deliveryId: "delivery-1", - replyToMessageId: "111111111111111111", - })).resolves.toMatchObject({ externalMessageId: "444444444444444444" }); - - expect(fetchImpl).toHaveBeenCalledWith( - `${DISCORD_API_BASE_URL}/channels/222222222222222222/messages`, - expect.objectContaining({ method: "POST", headers: expect.objectContaining({ authorization: "Bot secret-token" }) }), - ); - const body = JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body)); - expect(body).toEqual(expect.objectContaining({ - allowed_mentions: { parse: [] }, - nonce: stableDiscordNonce("delivery-1"), - enforce_nonce: true, - message_reference: { message_id: "111111111111111111", fail_if_not_exists: false }, - })); - }); - - it("honors one 429 retry and does not create a retry storm", async () => { - const wait = vi.fn(async () => undefined); - const fetchImpl = vi.fn() - .mockResolvedValueOnce(new Response("rate limited", { status: 429, headers: { "retry-after": "1.25" } })) - .mockResolvedValueOnce(new Response("still limited", { status: 429, headers: { "retry-after": "2" } })); - const client = new DiscordOfficialApiClient({ fetch: fetchImpl, wait, now: () => 1_000 }); - - await expect(client.sendReply({ - botToken: "secret-token", - channelId: "222222222222222222", - content: "reply", - deliveryId: "delivery-1", - })).rejects.toMatchObject({ code: "rate_limited", retryable: true, retryAfterMs: 2_000 }); - expect(fetchImpl).toHaveBeenCalledTimes(2); - expect(wait).toHaveBeenCalledWith(1_250, undefined); - }); - - it("honors the JSON retry_after fallback when Discord omits rate-limit headers", async () => { - const wait = vi.fn(async () => undefined); - const fetchImpl = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ retry_after: 0.25, global: false }), { status: 429 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ id: "444444444444444444" }), { status: 200 })); - const client = new DiscordOfficialApiClient({ fetch: fetchImpl, wait, now: () => 1_000 }); - - await expect(client.sendReply({ - botToken: "secret-token", - channelId: "222222222222222222", - content: "reply", - deliveryId: "delivery-1", - })).resolves.toMatchObject({ externalMessageId: "444444444444444444" }); - expect(wait).toHaveBeenCalledWith(250, undefined); - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it.each([ - [401, "invalid_auth"], - [403, "missing_permissions"], - [429, "rate_limited"], - ] as const)("classifies credential HTTP %s without exposing the token", async (status, classification) => { - const client = new DiscordOfficialApiClient({ - fetch: vi.fn(async () => new Response(`server echoed highly-sensitive-bot-token`, { status })), - }); - const result = await client.verifyCredentials("highly-sensitive-bot-token"); - expect(result).toMatchObject({ valid: false, classification }); - expect(JSON.stringify(result)).not.toContain("highly-sensitive-bot-token"); - }); - - it("uses only the read-only current-user endpoint for credential verification", async () => { - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ - id: "555555555555555555", - username: "codeux-bot", - }), { status: 200 })); - const client = new DiscordOfficialApiClient({ fetch: fetchImpl }); - - await expect(client.verifyCredentials("secret-token")).resolves.toEqual({ - valid: true, - classification: "verified", - botUserId: "555555555555555555", - botUsername: "codeux-bot", - issues: [], - }); - expect(fetchImpl).toHaveBeenCalledWith(`${DISCORD_API_BASE_URL}/users/@me`, expect.objectContaining({ method: "GET" })); - }); - - it("classifies timeouts, cancellation, and ambiguous network outcomes", async () => { - const timedOut = new DOMException("timed out", "TimeoutError"); - const timeoutClient = new DiscordOfficialApiClient({ fetch: vi.fn(async () => { throw timedOut; }) }); - expect(await timeoutClient.verifyCredentials("token")).toMatchObject({ classification: "timeout" }); - - const controller = new AbortController(); - controller.abort(); - const cancelledClient = new DiscordOfficialApiClient({ fetch: vi.fn(async () => { throw new Error("cancelled"); }) }); - expect(await cancelledClient.verifyCredentials("token", controller.signal)).toMatchObject({ classification: "cancelled" }); - - const networkClient = new DiscordOfficialApiClient({ fetch: vi.fn(async () => { throw new Error("token in socket error"); }) }); - const result = await networkClient.verifyCredentials("token in socket error"); - expect(result).toMatchObject({ classification: "ambiguous_network" }); - expect(JSON.stringify(result)).not.toContain("token in socket error"); - }); - - it("uses typed errors without retaining secrets", () => { - const error = new DiscordApiError("invalid_auth", "Discord rejected bot authentication.", false, 401); - expect(JSON.stringify(error)).not.toContain("secret-token"); - }); -}); - -function signedRequest(rawBody: string) { - const signature = sign(null, Buffer.from(`${TIMESTAMP}${rawBody}`), PRIVATE_KEY).toString("hex"); - return { - rawBody, - publicKey: PUBLIC_KEY, - now: NOW, - headers: { - "X-Signature-Ed25519": signature, - "X-Signature-Timestamp": TIMESTAMP, - }, - }; -} - -function outboundContext(mode: "webhook" | "official_api"): ChatConnectorOutboundContext { - const connection = { - id: "connection-1", - providerKind: "discord", - displayName: "Discord", - bridgeMode: mode, - status: "active", - enabled: true, - setup: { gatewayUrl: "https://bridge.example.test/discord" }, - secrets: { botToken: "discord-test-token" }, - credentials: [], - createdAt: NOW.toISOString(), - updatedAt: NOW.toISOString(), - } satisfies ChatProviderConnectionInternalRecord; - const binding = { - id: "binding-1", - providerConnectionId: connection.id, - providerKind: "discord", - externalChannelId: "222222222222222222", - externalChannelName: "triage", - externalChannelMetadata: null, - projectId: "project-1", - agentPresetId: null, - routingHints: null, - enabled: true, - inboundEnabled: true, - outboundEnabled: true, - suppressRichWidgets: true, - createdAt: NOW.toISOString(), - updatedAt: NOW.toISOString(), - } satisfies ChatProviderChannelBindingRecord; - const delivery = { - id: "delivery-1", - providerConnectionId: connection.id, - providerKind: "discord", - channelBindingId: binding.id, - externalChannelId: binding.externalChannelId, - externalMessageId: null, - direction: "outbound", - status: "sending", - attemptCount: 1, - lastError: null, - conversationThreadId: "thread-1", - conversationMessageId: "conversation-message-1", - payload: null, - createdAt: NOW.toISOString(), - updatedAt: NOW.toISOString(), - } satisfies ChatProviderMessageDeliveryRecord; - return { - connection, - binding, - delivery, - correlationId: "correlation-1", - payload: { - providerKind: "discord", - providerConnectionId: connection.id, - channelId: binding.externalChannelId, - threadId: "thread-1", - conversationMessageId: "conversation-message-1", - replyText: "Fixed the workflow", - replyToExternalMessageId: "111111111111111111", - metadata: {}, - }, - }; -} - diff --git a/tests/backend/domain/chat-connectors/microsoft-teams.test.ts b/tests/backend/domain/chat-connectors/microsoft-teams.test.ts deleted file mode 100644 index dce53ddc20..0000000000 --- a/tests/backend/domain/chat-connectors/microsoft-teams.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildMicrosoftTeamsActivityReplyRequest, - microsoftTeamsChatConnectorProfile, - normalizeMicrosoftTeamsActivity, - UnsupportedMicrosoftTeamsActivityError, - verifyMicrosoftTeamsConfiguration, - type MicrosoftTeamsConversationReference, -} from "../../../../src/domain/chat-connectors/providers/microsoft-teams.js"; -import { normalizeInboundPayload } from "../../../../src/services/chat-provider-ingress-service.js"; -import type { ChatProviderConnectionInternalRecord } from "../../../../src/contracts/chat-provider-types.js"; - -describe("Microsoft Teams chat connector profile", () => { - it("adds official_api app identity fields while retaining managed and webhook bridges", () => { - expect(microsoftTeamsChatConnectorProfile.supportedTransportModes).toEqual([ - "managed_bridge", - "webhook", - "official_api", - ]); - const modes = microsoftTeamsChatConnectorProfile.setupSchema.bridgeModes; - expect(modes.slice(0, 2).map((mode) => mode.mode)).toEqual(["managed_bridge", "webhook"]); - expect(modes[2]).toMatchObject({ - mode: "official_api", - integration: "official_api", - setupFields: [ - { key: "microsoftAppId", required: true }, - { key: "applicationType", type: "select", options: ["MultiTenant", "SingleTenant"] }, - { key: "tenantId", required: false }, - ], - secretFields: [{ key: "clientSecret", required: true }], - }); - expect(modes[2].setupFields.some((field) => field.key === "serviceUrl")).toBe(false); - }); - - it("requires a tenant for single-tenant app registrations", () => { - expect(verifyMicrosoftTeamsConfiguration("official_api", { - microsoftAppId: "bot-app-id", - applicationType: "SingleTenant", - }, { clientSecret: "write-only" })).toEqual({ - valid: false, - issues: ["Missing required setup field for SingleTenant application: tenantId"], - }); - expect(verifyMicrosoftTeamsConfiguration("official_api", { - microsoftAppId: "bot-app-id", - applicationType: "MultiTenant", - }, { clientSecret: "write-only" })).toEqual({ valid: true, issues: [] }); - }); - - it("normalizes message Activities, removes only the bot mention, and preserves Teams context", () => { - const normalized = normalizeMicrosoftTeamsActivity(activityFixture()); - - expect(normalized).toEqual({ - activityType: "message", - externalChannelId: "conversation-1", - externalChannelName: "Engineering", - externalSenderId: "aad-user-1", - externalSenderName: "Taylor", - textBody: "please review with Jordan", - externalMessageId: "activity-1", - timestamp: "2026-07-13T12:00:00.000Z", - channelId: "msteams", - locale: "en-US", - tenantId: "tenant-1", - teamId: "team-1", - teamsChannelId: "channel-1", - replyToId: "parent-activity", - conversation: { - id: "conversation-1", - name: "Conversation name", - conversationType: "channel", - isGroup: true, - }, - }); - }); - - it("retains replyToId as the external message ID for legacy bridge payloads without an ID", () => { - const normalized = normalizeInboundPayload({ - ...buildOfficialConnection(), - bridgeMode: "managed_bridge", - }, { - replyToId: "legacy-reply-1", - text: "Legacy bridge message", - conversation: { id: "legacy-conversation" }, - from: { id: "legacy-sender" }, - }); - - expect(normalized).toMatchObject({ - externalMessageId: "legacy-reply-1", - externalChannelId: "legacy-conversation", - externalSenderId: "legacy-sender", - textBody: "Legacy bridge message", - }); - }); - - it("rejects non-message Activities before generic ingress can create a chat message", () => { - const connection = buildOfficialConnection(); - expect(() => normalizeInboundPayload(connection, { - ...activityFixture(), - type: "conversationUpdate", - })).toThrow(UnsupportedMicrosoftTeamsActivityError); - }); - - it("normalizes Bot Emulator-shaped message fixtures without treating localhost as trusted transport", () => { - const normalized = normalizeMicrosoftTeamsActivity({ - type: "message", - id: "emulator-activity", - channelId: "emulator", - serviceUrl: "http://localhost:61570", - text: "hello from Emulator", - from: { id: "emulator-user", name: "User" }, - recipient: { id: "bot-app-id", name: "Bot" }, - conversation: { id: "emulator-conversation" }, - timestamp: "2026-07-13T12:00:00.000Z", - }); - - expect(normalized).toMatchObject({ - externalChannelId: "emulator-conversation", - externalSenderId: "emulator-user", - textBody: "hello from Emulator", - }); - expect(normalized).not.toHaveProperty("serviceUrl"); - }); - - it("builds the documented reply path only from a validated conversation reference", () => { - const reference = conversationReference(); - const request = buildMicrosoftTeamsActivityReplyRequest(reference, "Reply text", "correlation-1"); - - expect(request).toMatchObject({ - transport: "http", - url: "https://smba.trafficmanager.net/teams/v3/conversations/conversation-1/activities/activity-1", - bearerSecretKeys: [], - body: { - type: "message", - from: { id: "bot-app-id" }, - recipient: { id: "user-1" }, - conversation: { id: "conversation-1" }, - replyToId: "activity-1", - text: "Reply text", - }, - }); - - expect(() => buildMicrosoftTeamsActivityReplyRequest({ - ...reference, - serviceUrl: "https://arbitrary.botframework.com/teams", - }, "Blocked", "correlation-2")).toThrow("invalid service URL"); - }); -}); - -function activityFixture(): Record { - return { - type: "message", - id: "activity-1", - replyToId: "parent-activity", - timestamp: "2026-07-13T12:00:00.000Z", - serviceUrl: "https://smba.trafficmanager.net/teams", - channelId: "msteams", - locale: "en-US", - from: { id: "29:user", aadObjectId: "aad-user-1", name: "Taylor" }, - recipient: { id: "bot-app-id", name: "Code UX" }, - conversation: { - id: "conversation-1", - name: "Conversation name", - conversationType: "channel", - isGroup: true, - }, - channelData: { - tenant: { id: "tenant-1" }, - team: { id: "team-1" }, - channel: { id: "channel-1", name: "Engineering" }, - }, - text: "Code UX please review with Jordan", - entities: [ - { type: "mention", text: "Code UX", mentioned: { id: "bot-app-id", name: "Code UX" } }, - { type: "mention", text: "Jordan", mentioned: { id: "user-jordan", name: "Jordan" } }, - ], - }; -} - -function conversationReference(): MicrosoftTeamsConversationReference { - return { - activityId: "activity-1", - serviceUrl: "https://smba.trafficmanager.net/teams", - serviceUrlValidated: true, - channelId: "msteams", - locale: "en-US", - tenantId: "tenant-1", - teamId: "team-1", - teamsChannelId: "channel-1", - conversation: { id: "conversation-1", conversationType: "channel", isGroup: true }, - bot: { id: "bot-app-id", name: "Code UX" }, - user: { id: "user-1", name: "Taylor" }, - }; -} - -function buildOfficialConnection(): ChatProviderConnectionInternalRecord { - return { - id: "connection-1", - providerKind: "microsoft-teams", - displayName: "Teams official API", - bridgeMode: "official_api", - status: "active", - enabled: true, - setup: { - microsoftAppId: "bot-app-id", - applicationType: "SingleTenant", - tenantId: "tenant-1", - }, - secrets: { clientSecret: "secret" }, - createdAt: "2026-07-13T00:00:00.000Z", - updatedAt: "2026-07-13T00:00:00.000Z", - }; -} diff --git a/tests/backend/domain/chat-connectors/registry.test.ts b/tests/backend/domain/chat-connectors/registry.test.ts index f4ac12a4e3..cb4481a104 100644 --- a/tests/backend/domain/chat-connectors/registry.test.ts +++ b/tests/backend/domain/chat-connectors/registry.test.ts @@ -80,19 +80,12 @@ describe("chat connector registry", () => { modes: [ { mode: "managed_bridge", integration: "managed_plugin", setup: ["pluginName", "tenantId"], secrets: ["bridgeApiKey"] }, { mode: "webhook", integration: "webhook", setup: ["botEndpointUrl", "tenantId"], secrets: ["botAppPassword", "webhookSecret"] }, - { mode: "official_api", integration: "official_api", setup: ["microsoftAppId", "applicationType", "tenantId"], secrets: ["clientSecret"] }, ], }, discord: { defaultMode: "webhook", modes: [ { mode: "webhook", integration: "bot_gateway", setup: ["gatewayUrl", "applicationId"], secrets: ["botToken", "webhookSecret"] }, - { - mode: "official_api", - integration: "official_api", - setup: ["applicationId", "publicKey", "intents"], - secrets: ["botToken"], - }, ], }, }); @@ -110,11 +103,7 @@ describe("chat connector registry", () => { expect(getChatConnectorProfileForMode("telegram", "official_api").kind).toBe("telegram"); expect(getChatConnectorProfileForMode("whatsapp", "official_api").kind).toBe("whatsapp"); expect(getChatConnectorProfileForMode("slack", "official_api").kind).toBe("slack"); - expect(getChatConnectorProfileForMode("discord", "official_api").kind).toBe("discord"); - expect(getChatConnectorProfileForMode("microsoft-teams", "official_api").kind).toBe("microsoft-teams"); - for (const kind of CHAT_CONNECTOR_KINDS.filter( - (candidate) => !["telegram", "whatsapp", "slack", "discord", "microsoft-teams"].includes(candidate), - )) { + for (const kind of CHAT_CONNECTOR_KINDS.filter((candidate) => !["telegram", "whatsapp", "slack"].includes(candidate))) { expect(() => getChatConnectorProfileForMode(kind, "official_api" as ChatProviderBridgeMode)).toThrow( `Unsupported bridge mode for ${kind}: official_api`, ); diff --git a/tests/backend/domain/chat-connectors/slack.test.ts b/tests/backend/domain/chat-connectors/slack.test.ts index 70c5711a9a..2007b33ca1 100644 --- a/tests/backend/domain/chat-connectors/slack.test.ts +++ b/tests/backend/domain/chat-connectors/slack.test.ts @@ -176,7 +176,10 @@ describe("Slack chat connector profile", () => { }, })); registerChatProviderIngressRoutes(app, { - chatProviderRepository: { getConnectionInternal: () => providerConnection }, + chatProviderRepository: { + getConnectionInternal: () => providerConnection, + insertIngressReplayReceipt: () => true, + }, chatProviderIngressService: { processInbound }, } as unknown as DashboardDependencies); diff --git a/tests/backend/helpers/chat-provider-secret-fixture.ts b/tests/backend/helpers/chat-provider-secret-fixture.ts new file mode 100644 index 0000000000..80667cfb42 --- /dev/null +++ b/tests/backend/helpers/chat-provider-secret-fixture.ts @@ -0,0 +1,14 @@ +import type { ChatProviderRepository } from "../../../src/repositories/chat-provider-repository.js"; +import { ChatProviderSecretService } from "../../../src/services/chat-provider-secret-service.js"; +import type { KeyProvider } from "../../../src/services/credentials/key-provider.js"; + +export function createChatProviderSecretFixture(repository: ChatProviderRepository): ChatProviderSecretService { + const key = Buffer.alloc(32, 31); + const keyProvider: KeyProvider = { + providerName: "chat-provider-test-key", + health: async () => ({ available: true, secure: true, provider: "chat-provider-test-key", keyId: "root", keyVersion: 1 }), + getActiveKey: async () => ({ key: Buffer.from(key), keyId: "root", version: 1 }), + getKey: async () => ({ key: Buffer.from(key), keyId: "root", version: 1 }), + }; + return new ChatProviderSecretService(repository, keyProvider); +} diff --git a/tests/backend/mcp/management-chat-provider-actions.test.ts b/tests/backend/mcp/management-chat-provider-actions.test.ts index 7e73c2df59..eab40c88e6 100644 --- a/tests/backend/mcp/management-chat-provider-actions.test.ts +++ b/tests/backend/mcp/management-chat-provider-actions.test.ts @@ -8,6 +8,8 @@ import { ChatProviderRepository } from "../../../src/repositories/chat-provider- import { ConnectionChatRepository } from "../../../src/repositories/connection-chat-repository.js"; import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; import type { ManagementResponseEnvelope } from "../../../src/contracts/internal-management-types.js"; +import { createChatProviderSecretFixture } from "../helpers/chat-provider-secret-fixture.js"; +import type { ChatProviderSecretService } from "../../../src/services/chat-provider-secret-service.js"; const tempDirs: string[] = []; const openStorages: AppDbStorage[] = []; @@ -18,18 +20,21 @@ async function createHarness(): Promise<{ providerRepository: ChatProviderRepository; conversationRepository: ConnectionChatRepository; actions: ChatProviderActions; + secretService: ChatProviderSecretService; }> { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-mcp-chat-providers-")); tempDirs.push(dir); const storage = new AppDbStorage(path.join(dir, "app.db")); openStorages.push(storage); const providerRepository = new ChatProviderRepository(storage); + const secretService = createChatProviderSecretFixture(providerRepository); return { storage, projectRepository: new ProjectManagementRepository(storage), providerRepository, conversationRepository: new ConnectionChatRepository(storage), - actions: new ChatProviderActions(providerRepository), + actions: new ChatProviderActions(providerRepository, secretService), + secretService, }; } @@ -77,7 +82,7 @@ describe("ChatProviderActions", () => { }); it("creates, lists, gets, and updates redacted provider connections", async () => { - const { actions, providerRepository } = await createHarness(); + const { actions, secretService } = await createHarness(); const createdResult = expectResult(await actions.handleChatProviderAction({ domain: "chat_providers", @@ -112,7 +117,7 @@ describe("ChatProviderActions", () => { expect(JSON.stringify(createdResult)).not.toContain("must-not-be-saved-in-setup"); expect(connection).not.toHaveProperty("secrets"); expect(connection).toHaveProperty("credentials"); - expect(providerRepository.getConnectionInternal(connection.id as string)?.secrets).toEqual({ + expect((await secretService.resolveConnection(connection.id as string)).secrets).toEqual({ botToken: "telegram-secret-value", }); @@ -146,7 +151,7 @@ describe("ChatProviderActions", () => { enabled: false, setup: { webhookUrl: "https://example.test/telegram-v2" }, }); - expect(providerRepository.getConnectionInternal(connection.id as string)?.secrets).toEqual({ + expect((await secretService.resolveConnection(connection.id as string)).secrets).toEqual({ botToken: "telegram-secret-value", }); }); @@ -176,8 +181,8 @@ describe("ChatProviderActions", () => { }); it("requires one-use approval before replacing non-empty secret payloads", async () => { - const { actions, providerRepository } = await createHarness(); - const connection = providerRepository.createConnection({ + const { actions, secretService } = await createHarness(); + const connection = await secretService.createConnection({ providerKind: "slack", displayName: "Slack bridge", bridgeMode: "webhook", @@ -195,7 +200,7 @@ describe("ChatProviderActions", () => { expect(first.approvalRequired).toBe(true); expect(JSON.stringify(first)).not.toContain("new-secret"); - expect(providerRepository.getConnectionInternal(connection.id)?.secrets).toEqual({ signingSecret: "old-secret" }); + expect((await secretService.resolveConnection(connection.id)).secrets).toEqual({ signingSecret: "old-secret" }); const approved = expectResult(await actions.handleChatProviderAction({ domain: "chat_providers", @@ -209,7 +214,7 @@ describe("ChatProviderActions", () => { expect(approved.connection).toMatchObject({ id: connection.id }); expect(JSON.stringify(approved)).not.toContain("new-secret"); - expect(providerRepository.getConnectionInternal(connection.id)?.secrets).toEqual({ signingSecret: "new-secret" }); + expect((await secretService.resolveConnection(connection.id)).secrets).toEqual({ signingSecret: "new-secret" }); }); it("requires approval before deleting connections and channel bindings", async () => { diff --git a/tests/backend/repositories/chat-provider-repository.test.ts b/tests/backend/repositories/chat-provider-repository.test.ts index 87d6935d74..0e7dd5279a 100644 --- a/tests/backend/repositories/chat-provider-repository.test.ts +++ b/tests/backend/repositories/chat-provider-repository.test.ts @@ -3,29 +3,47 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; -import { ChatProviderRepository } from "../../../src/repositories/chat-provider-repository.js"; +import { + ChatProviderConcurrentModificationError, + ChatProviderRepository, +} from "../../../src/repositories/chat-provider-repository.js"; import { ConnectionChatRepository } from "../../../src/repositories/connection-chat-repository.js"; import { ensureChatProviderTables } from "../../../src/repositories/db/app-db-migrations.js"; import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { ChatProviderSecretService } from "../../../src/services/chat-provider-secret-service.js"; +import type { KeyProvider } from "../../../src/services/credentials/key-provider.js"; const tempDirs: string[] = []; const openStorages: AppDbStorage[] = []; +function createKeyProvider(): KeyProvider { + const key = Buffer.alloc(32, 23); + return { + providerName: "repository-test-key", + health: async () => ({ available: true, secure: true, provider: "repository-test-key", keyId: "root", keyVersion: 1 }), + getActiveKey: async () => ({ key: Buffer.from(key), keyId: "root", version: 1 }), + getKey: async () => ({ key: Buffer.from(key), keyId: "root", version: 1 }), + }; +} + async function createRepositories(): Promise<{ storage: AppDbStorage; projectRepository: ProjectManagementRepository; providerRepository: ChatProviderRepository; conversationRepository: ConnectionChatRepository; + secretService: ChatProviderSecretService; }> { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-chat-provider-repo-")); tempDirs.push(dir); const storage = new AppDbStorage(path.join(dir, "app.db")); openStorages.push(storage); + const providerRepository = new ChatProviderRepository(storage); return { storage, projectRepository: new ProjectManagementRepository(storage), - providerRepository: new ChatProviderRepository(storage), + providerRepository, conversationRepository: new ConnectionChatRepository(storage), + secretService: new ChatProviderSecretService(providerRepository, createKeyProvider()), }; } @@ -44,7 +62,7 @@ describe("ChatProviderRepository", () => { }); it("creates connections from setup schemas and redacts public credentials", async () => { - const { providerRepository } = await createRepositories(); + const { providerRepository, secretService } = await createRepositories(); const schemas = providerRepository.getSetupSchemas(); expect(schemas.map((schema) => schema.kind)).toEqual([ @@ -68,7 +86,7 @@ describe("ChatProviderRepository", () => { integration: "bot_gateway", }); - const connection = providerRepository.createConnection({ + const connection = await secretService.createConnection({ providerKind: "slack", displayName: "Slack bridge", bridgeMode: "webhook", @@ -108,7 +126,7 @@ describe("ChatProviderRepository", () => { ]), ); - const internal = providerRepository.getConnectionInternal(connection.id); + const internal = await secretService.resolveConnection(connection.id); expect(internal?.secrets).toMatchObject({ signingSecret: "secret-signing-value", botToken: "xoxb-secret", @@ -116,8 +134,8 @@ describe("ChatProviderRepository", () => { }); it("preserves secrets when updates omit them and clears them only when requested", async () => { - const { providerRepository } = await createRepositories(); - const connection = providerRepository.createConnection({ + const { providerRepository, secretService } = await createRepositories(); + const connection = await secretService.createConnection({ providerKind: "telegram", displayName: "Telegram bridge", bridgeMode: "webhook", @@ -125,21 +143,21 @@ describe("ChatProviderRepository", () => { setup: { webhookUrl: "https://example.test/telegram" }, }); - const updated = providerRepository.updateConnection(connection.id, { + const updated = await secretService.updateConnection(connection.id, { displayName: "Telegram bridge renamed", setup: { webhookUrl: "https://example.test/telegram-v2", botToken: "setup-secret" }, }); expect(updated.displayName).toBe("Telegram bridge renamed"); expect(updated.setup).toEqual({ webhookUrl: "https://example.test/telegram-v2" }); - expect(providerRepository.getConnectionInternal(connection.id)?.secrets).toEqual({ + expect((await secretService.resolveConnection(connection.id)).secrets).toEqual({ botToken: "telegram-secret", }); - providerRepository.updateConnection(connection.id, { secrets: null }); + await secretService.updateConnection(connection.id, { secrets: null }); const redacted = providerRepository.getConnection(connection.id); - expect(providerRepository.getConnectionInternal(connection.id)?.secrets).toBeNull(); + expect((await secretService.resolveConnection(connection.id)).secrets).toBeNull(); expect(redacted?.credentials).toEqual( expect.arrayContaining([ expect.objectContaining({ key: "botToken", configured: false, redactedValue: null }), @@ -328,7 +346,7 @@ describe("ChatProviderRepository", () => { }); it("deletes bindings directly and cascades bindings plus deliveries when a provider is deleted", async () => { - const { projectRepository, providerRepository, conversationRepository } = await createRepositories(); + const { storage, projectRepository, providerRepository, conversationRepository } = await createRepositories(); const project = projectRepository.createProject({ name: "Provider Delete Project", sourceType: "local", @@ -370,12 +388,27 @@ describe("ChatProviderRepository", () => { externalChannelId: "imessage-live", conversationMessageId: conversationMessage.id, }); + providerRepository.insertIngressReplayReceipt(connection.id, "delete-receipt", "2026-06-01T12:05:00.000Z"); + providerRepository.createProviderSession({ + providerConnectionId: connection.id, + channelBindingId: binding.id, + externalChannelId: "imessage-live", + sessionKey: "delete-session", + state: { cursor: 1 }, + }); expect(providerRepository.deleteConnection(connection.id)).toBe(true); expect(providerRepository.getConnection(connection.id)).toBeNull(); expect(providerRepository.listChannelBindings({ providerConnectionId: connection.id })).toEqual([]); expect(providerRepository.getDelivery(inbound.delivery.id)).toBeNull(); expect(providerRepository.getDelivery(outbound.id)).toBeNull(); + const durableChildren = storage.getDatabase().prepare(` + SELECT + (SELECT COUNT(*) FROM chat_provider_ingress_replay_receipts) AS receipts, + (SELECT COUNT(*) FROM chat_provider_sessions) AS sessions, + (SELECT COUNT(*) FROM chat_provider_connection_secrets) AS secrets + `).get() as { receipts: number; sessions: number; secrets: number }; + expect(durableChildren).toEqual({ receipts: 0, sessions: 0, secrets: 0 }); }); it("creates chat provider migration tables and indexes idempotently", async () => { @@ -391,15 +424,21 @@ describe("ChatProviderRepository", () => { WHERE type = 'table' AND name IN ( 'chat_provider_connections', + 'chat_provider_connection_secrets', 'chat_provider_channel_bindings', - 'chat_provider_message_deliveries' + 'chat_provider_message_deliveries', + 'chat_provider_ingress_replay_receipts', + 'chat_provider_sessions' ) ORDER BY name ASC `).all() as Array<{ name: string }>; expect(tableRows.map((row) => row.name)).toEqual([ "chat_provider_channel_bindings", + "chat_provider_connection_secrets", "chat_provider_connections", + "chat_provider_ingress_replay_receipts", "chat_provider_message_deliveries", + "chat_provider_sessions", ]); const indexNames = [ @@ -409,10 +448,129 @@ describe("ChatProviderRepository", () => { "idx_chat_provider_channel_bindings_provider_channel", "idx_chat_provider_message_deliveries_inbound_dedupe", "idx_chat_provider_message_deliveries_pending_outbound", + "idx_chat_provider_ingress_replay_expiry", + "idx_chat_provider_sessions_connection", ]; for (const indexName of indexNames) { const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?").get(indexName) as { name: string } | undefined; expect(row?.name).toBe(indexName); } }); + + it("resets sanitized verification for transport changes but preserves it for display-only changes", async () => { + const { providerRepository } = await createRepositories(); + const connection = providerRepository.createConnection({ + providerKind: "slack", + displayName: "Verification fixture", + bridgeMode: "webhook", + setup: { eventsUrl: "https://example.test/events" }, + }); + const verified = providerRepository.updateVerification(connection.id, "verified", { + endpoint: "reachable", + signingSecret: "must-be-redacted", + }); + expect(verified.verificationDetails).toEqual({ endpoint: "reachable", signingSecret: "[REDACTED]" }); + + const renamed = providerRepository.updateConnection(connection.id, { displayName: "Renamed fixture" }); + expect(renamed.verificationStatus).toBe("verified"); + expect(renamed.verifiedAt).not.toBeNull(); + + const changed = providerRepository.updateConnection(connection.id, { + setup: { eventsUrl: "https://example.test/events-v2" }, + }); + expect(changed).toMatchObject({ verificationStatus: "unverified", verificationDetails: null, verifiedAt: null }); + }); + + it("atomically deduplicates inbound deliveries and durable replay receipts", async () => { + const { providerRepository } = await createRepositories(); + const connection = providerRepository.createConnection({ providerKind: "telegram", displayName: "Race fixture" }); + const attempts = await Promise.all(Array.from({ length: 8 }, async () => providerRepository.recordInboundMessage({ + providerConnectionId: connection.id, + externalChannelId: "race-channel", + externalMessageId: "race-message", + }))); + expect(attempts.filter((attempt) => !attempt.duplicate)).toHaveLength(1); + expect(new Set(attempts.map((attempt) => attempt.delivery.id))).toHaveLength(1); + + const expiresAt = "2026-06-01T12:01:00.000Z"; + expect(providerRepository.insertIngressReplayReceipt(connection.id, "same-receipt", expiresAt)).toBe(true); + expect(providerRepository.insertIngressReplayReceipt(connection.id, "same-receipt", expiresAt)).toBe(false); + expect(providerRepository.listIngressReplayReceipts(connection.id)).toHaveLength(1); + expect(providerRepository.cleanupExpiredIngressReplayReceipts(new Date(expiresAt))).toBe(1); + expect(providerRepository.insertIngressReplayReceipt(connection.id, "same-receipt", "2026-06-01T12:02:00.000Z")).toBe(true); + }); + + it("enforces binding ownership and compare-and-set provider session updates", async () => { + const { projectRepository, providerRepository } = await createRepositories(); + const project = projectRepository.createProject({ name: "Session project", sourceType: "local", sourceRef: "/tmp/session-project" }); + const owner = providerRepository.createConnection({ providerKind: "discord", displayName: "Session owner" }); + const other = providerRepository.createConnection({ providerKind: "discord", displayName: "Other owner" }); + const binding = providerRepository.createChannelBinding({ + providerConnectionId: owner.id, + externalChannelId: "session-channel", + externalChannelName: "Session channel", + projectId: project.id, + }); + expect(() => providerRepository.recordInboundMessage({ + providerConnectionId: other.id, + channelBindingId: binding.id, + externalChannelId: "session-channel", + externalMessageId: "wrong-owner", + })).toThrow("does not belong"); + expect(() => providerRepository.createProviderSession({ + providerConnectionId: other.id, + channelBindingId: binding.id, + externalChannelId: "session-channel", + sessionKey: "wrong-owner", + state: {}, + })).toThrow("does not belong"); + + const session = providerRepository.createProviderSession({ + providerConnectionId: owner.id, + channelBindingId: binding.id, + externalChannelId: "session-channel", + sessionKey: "provider-native-session", + state: { cursor: 1 }, + expiresAt: "2026-06-01T12:01:00.000Z", + }); + const updated = providerRepository.compareAndSetProviderSession(session.id, 1, { cursor: 2 }); + expect(updated).toMatchObject({ version: 2, state: { cursor: 2 } }); + expect(() => providerRepository.compareAndSetProviderSession(session.id, 1, { cursor: 3 })) + .toThrow(ChatProviderConcurrentModificationError); + expect(providerRepository.cleanupExpiredProviderSessions(new Date("2026-06-01T12:02:00.000Z"))).toBe(1); + }); + + it("claims outbound deliveries with one lease owner and recovers stale leases", async () => { + const { projectRepository, providerRepository, conversationRepository } = await createRepositories(); + const project = projectRepository.createProject({ name: "Lease project", sourceType: "local", sourceRef: "/tmp/lease-project" }); + const message = conversationRepository.postDashboardMessage(project.id, { title: "Lease", bodyMarkdown: "Claim once" }); + const connection = providerRepository.createConnection({ providerKind: "microsoft-teams", displayName: "Lease owner" }); + const delivery = providerRepository.upsertOutboundDelivery({ + providerConnectionId: connection.id, + externalChannelId: "lease-channel", + conversationMessageId: message.id, + nextAttemptAt: "2026-06-01T12:00:00.000Z", + }); + + const claims = await Promise.all([ + Promise.resolve(providerRepository.claimOutboundDeliveries({ leaseOwner: "worker-a", leaseDurationMs: 1_000 })), + Promise.resolve(providerRepository.claimOutboundDeliveries({ leaseOwner: "worker-b", leaseDurationMs: 1_000 })), + ]); + expect(claims.flat()).toHaveLength(1); + const firstOwner = claims[0].length === 1 ? "worker-a" : "worker-b"; + const secondOwner = firstOwner === "worker-a" ? "worker-b" : "worker-a"; + expect(providerRepository.getDelivery(delivery.id)?.leaseOwner).toBe(firstOwner); + expect(() => providerRepository.completeOutboundDelivery(delivery.id, secondOwner, { status: "delivered" })) + .toThrow(ChatProviderConcurrentModificationError); + + vi.setSystemTime(new Date("2026-06-01T12:00:02.000Z")); + const recovered = providerRepository.claimOutboundDeliveries({ leaseOwner: secondOwner, leaseDurationMs: 1_000 }); + expect(recovered).toHaveLength(1); + expect(recovered[0]).toMatchObject({ id: delivery.id, leaseOwner: secondOwner }); + const released = providerRepository.releaseOutboundDelivery(delivery.id, secondOwner, { + status: "retryable_failure", + nextAttemptAt: "2026-06-01T12:01:00.000Z", + }); + expect(released).toMatchObject({ status: "retryable_failure", leaseOwner: null, nextAttemptAt: "2026-06-01T12:01:00.000Z" }); + }); }); diff --git a/tests/backend/server/chat-provider-ingress-routes.test.ts b/tests/backend/server/chat-provider-ingress-routes.test.ts index 44408c7530..193f181d20 100644 --- a/tests/backend/server/chat-provider-ingress-routes.test.ts +++ b/tests/backend/server/chat-provider-ingress-routes.test.ts @@ -11,6 +11,8 @@ import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; import { ChatProviderRepository } from "../../../src/repositories/chat-provider-repository.js"; import { ConnectionChatRepository } from "../../../src/repositories/connection-chat-repository.js"; import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { createChatProviderSecretFixture } from "../helpers/chat-provider-secret-fixture.js"; +import type { ChatProviderSecretService } from "../../../src/services/chat-provider-secret-service.js"; import { ChatProviderIngressService } from "../../../src/services/chat-provider-ingress-service.js"; import type { ChatThreadRuntimeService } from "../../../src/services/chat-thread-runtime-service.js"; @@ -20,6 +22,7 @@ interface TestServerContext { tempDir: string; storage: AppDbStorage; chatProviderRepository: ChatProviderRepository; + chatProviderSecretService: ChatProviderSecretService; connectionChatRepository: ConnectionChatRepository; projectManagementRepository: ProjectManagementRepository; postMessage: ReturnType; @@ -43,7 +46,7 @@ describe("chat provider ingress routes", () => { it("accepts an authenticated bearer bridge request and deduplicates repeated external messages", async () => { const context = await startTestServer(); const project = createProject(context, "bearer-ingress"); - const connection = context.chatProviderRepository.createConnection({ + const connection = await context.chatProviderSecretService.createConnection({ providerKind: "slack", displayName: "Slack bridge", bridgeMode: "managed_bridge", @@ -99,7 +102,7 @@ describe("chat provider ingress routes", () => { it("verifies webhook HMAC signatures before processing inbound payloads", async () => { const context = await startTestServer(); const project = createProject(context, "hmac-ingress"); - const connection = context.chatProviderRepository.createConnection({ + const connection = await context.chatProviderSecretService.createConnection({ providerKind: "discord", displayName: "Discord gateway", bridgeMode: "webhook", @@ -144,7 +147,7 @@ describe("chat provider ingress routes", () => { it("handles the official WhatsApp subscription challenge with 200 and 403 responses", async () => { const context = await startTestServer(); - const connection = createOfficialWhatsAppConnection(context); + const connection = await createOfficialWhatsAppConnection(context); const endpoint = `${context.baseUrl}/api/chat-providers/ingress/${connection.id}`; const accepted = await fetch(`${endpoint}?${new URLSearchParams({ @@ -169,7 +172,7 @@ describe("chat provider ingress routes", () => { it("authenticates official WhatsApp POST callbacks from exact raw bytes without a timestamp", async () => { const context = await startTestServer(); const project = createProject(context, "whatsapp-raw-signature"); - const connection = createOfficialWhatsAppConnection(context); + const connection = await createOfficialWhatsAppConnection(context); context.chatProviderRepository.createChannelBinding({ providerConnectionId: connection.id, externalChannelId: "109876543210987", @@ -200,7 +203,7 @@ describe("chat provider ingress routes", () => { it("acknowledges official WhatsApp status callbacks without creating deliveries or messages", async () => { const context = await startTestServer(); - const connection = createOfficialWhatsAppConnection(context); + const connection = await createOfficialWhatsAppConnection(context); const rawBody = JSON.stringify(whatsappStatusWebhook()); const signature = `sha256=${createHmac("sha256", "whatsapp-app-secret").update(rawBody).digest("hex")}`; @@ -220,7 +223,7 @@ describe("chat provider ingress routes", () => { it("rejects unauthenticated and stale bridge requests without creating messages", async () => { const context = await startTestServer(); const project = createProject(context, "rejected-ingress"); - const connection = context.chatProviderRepository.createConnection({ + const connection = await context.chatProviderSecretService.createConnection({ providerKind: "slack", displayName: "Slack bridge", bridgeMode: "managed_bridge", @@ -260,7 +263,7 @@ describe("chat provider ingress routes", () => { const context = await startTestServer(); const projectA = createProject(context, "ambiguous-route-a"); const projectB = createProject(context, "ambiguous-route-b"); - const connection = context.chatProviderRepository.createConnection({ + const connection = await context.chatProviderSecretService.createConnection({ providerKind: "telegram", displayName: "Telegram gateway", bridgeMode: "managed_bridge", @@ -310,6 +313,7 @@ async function startTestServer(): Promise { const storage = new AppDbStorage(path.join(tempDir, "app.db")); openStorages.push(storage); const chatProviderRepository = new ChatProviderRepository(storage); + const chatProviderSecretService = createChatProviderSecretFixture(chatProviderRepository); const connectionChatRepository = new ConnectionChatRepository(storage); const projectManagementRepository = new ProjectManagementRepository(storage); const postMessage = vi.fn(async (projectId: string, input: Parameters[1]) => ( @@ -317,6 +321,7 @@ async function startTestServer(): Promise { )); const chatProviderIngressService = new ChatProviderIngressService({ chatProviderRepository, + chatProviderSecretService, chatThreadRuntimeService: { postMessage } as unknown as ChatThreadRuntimeService, }); const app = express(); @@ -327,6 +332,7 @@ async function startTestServer(): Promise { })); registerChatProviderIngressRoutes(app, { chatProviderRepository, + chatProviderSecretService, chatProviderIngressService, } as DashboardDependencies); const server = await new Promise((resolve) => { @@ -343,6 +349,7 @@ async function startTestServer(): Promise { tempDir, storage, chatProviderRepository, + chatProviderSecretService, connectionChatRepository, projectManagementRepository, postMessage, @@ -389,8 +396,8 @@ function postRawIngress( }); } -function createOfficialWhatsAppConnection(context: TestServerContext) { - return context.chatProviderRepository.createConnection({ +async function createOfficialWhatsAppConnection(context: TestServerContext) { + return context.chatProviderSecretService.createConnection({ providerKind: "whatsapp", displayName: "WhatsApp official connection", bridgeMode: "official_api", diff --git a/tests/backend/server/chat-provider-routes.test.ts b/tests/backend/server/chat-provider-routes.test.ts index 27353ffe63..ac9fce6dd1 100644 --- a/tests/backend/server/chat-provider-routes.test.ts +++ b/tests/backend/server/chat-provider-routes.test.ts @@ -10,6 +10,7 @@ import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; import { ConnectionChatRepository } from "../../../src/repositories/connection-chat-repository.js"; import { ChatProviderRepository } from "../../../src/repositories/chat-provider-repository.js"; import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { createChatProviderSecretFixture } from "../helpers/chat-provider-secret-fixture.js"; interface TestServerContext { baseUrl: string; @@ -347,12 +348,14 @@ async function startTestServer(): Promise { tempDirs.push(tempDir); const storage = new AppDbStorage(path.join(tempDir, "app.db")); const chatProviderRepository = new ChatProviderRepository(storage); + const chatProviderSecretService = createChatProviderSecretFixture(chatProviderRepository); const connectionChatRepository = new ConnectionChatRepository(storage); const projectManagementRepository = new ProjectManagementRepository(storage); const app = express(); app.use(express.json()); registerChatProviderRoutes(app, { chatProviderRepository, + chatProviderSecretService, } as DashboardDependencies); const server = await new Promise((resolve) => { const listening = app.listen(0, "127.0.0.1", () => resolve(listening)); diff --git a/tests/backend/services/chat-provider-ingress-service.test.ts b/tests/backend/services/chat-provider-ingress-service.test.ts index f3256b131d..f7a3062945 100644 --- a/tests/backend/services/chat-provider-ingress-service.test.ts +++ b/tests/backend/services/chat-provider-ingress-service.test.ts @@ -39,7 +39,6 @@ describe("ChatProviderIngressService", () => { displayName: "Slack ingress", bridgeMode: "managed_bridge", status: "active", - secrets: { bridgeApiKey: "bridge-token" }, }); const bindingA = context.providerRepository.createChannelBinding({ providerConnectionId: connection.id, @@ -112,7 +111,6 @@ describe("ChatProviderIngressService", () => { displayName: "Discord ingress", bridgeMode: "webhook", status: "active", - secrets: { botToken: "bot-token" }, }); context.providerRepository.createChannelBinding({ providerConnectionId: connection.id, @@ -156,7 +154,6 @@ describe("ChatProviderIngressService", () => { displayName: "Telegram ingress", bridgeMode: "webhook", status: "active", - secrets: { botToken: "telegram-token" }, }); for (const project of [projectA, projectB]) { context.providerRepository.createChannelBinding({ @@ -220,7 +217,7 @@ describe("ChatProviderIngressService", () => { ["imessage", { chatGuid: "chat-guid", chatName: "Family", sender: { handle: "+15550000", name: "Lee" }, guid: "imsg-1", text: "iMessage body" }, "chat-guid", "+15550000", "iMessage body", "imsg-1"], ["telegram", { message: { message_id: 7, date: 1783430400, text: "Telegram body", chat: { id: 88, title: "Ops" }, from: { id: 99, username: "ops-user" } } }, "88", "99", "Telegram body", "7"], ["slack", { event_id: "event-1", event: { channel: "C1", user: "U1", text: "Slack body", ts: "1783430400.000100" } }, "C1", "U1", "Slack body", "event-1"], - ["microsoft-teams", { type: "message", id: "activity-1", text: "Teams body", conversation: { id: "teams-conv", name: "Ops" }, from: { id: "aad-1", name: "Morgan" }, timestamp: "2026-07-07T12:00:00.000Z" }, "teams-conv", "aad-1", "Teams body", "activity-1"], + ["microsoft-teams", { id: "activity-1", text: "Teams body", conversation: { id: "teams-conv", name: "Ops" }, from: { id: "aad-1", name: "Morgan" }, timestamp: "2026-07-07T12:00:00.000Z" }, "teams-conv", "aad-1", "Teams body", "activity-1"], ["discord", { id: "discord-1", content: "Discord body", channel_id: "discord-channel", author: { id: "discord-user", username: "Riley" }, timestamp: "2026-07-07T12:00:00.000Z" }, "discord-channel", "discord-user", "Discord body", "discord-1"], ] as Array<[ChatProviderKind, Record, string, string, string, string]>)( "normalizes %s payloads into the internal inbound shape", diff --git a/tests/backend/services/chat-provider-outbound-service.test.ts b/tests/backend/services/chat-provider-outbound-service.test.ts index 88505f5130..8aaff29e04 100644 --- a/tests/backend/services/chat-provider-outbound-service.test.ts +++ b/tests/backend/services/chat-provider-outbound-service.test.ts @@ -7,6 +7,8 @@ import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; import { ChatProviderRepository } from "../../../src/repositories/chat-provider-repository.js"; import { ConnectionChatRepository } from "../../../src/repositories/connection-chat-repository.js"; import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { createChatProviderSecretFixture } from "../helpers/chat-provider-secret-fixture.js"; +import type { ChatProviderSecretService } from "../../../src/services/chat-provider-secret-service.js"; import { ChatProviderOutboundAdapterError, type ChatProviderOutboundAdapter, @@ -43,7 +45,7 @@ describe("ChatProviderOutboundService", () => { sourceType: "local", sourceRef: path.join(context.tempDir, "repo"), }); - const connection = context.providerRepository.createConnection({ + const connection = await context.secretService.createConnection({ providerKind: "slack", displayName: "Slack webhook", bridgeMode: "webhook", @@ -94,6 +96,7 @@ describe("ChatProviderOutboundService", () => { const service = new ChatProviderOutboundService({ chatProviderRepository: context.providerRepository, + chatProviderSecretService: context.secretService, }); const delivery = await service.deliverReply({ @@ -143,7 +146,10 @@ describe("ChatProviderOutboundService", () => { setup: { bridgeUrl: bridge.url }, secrets: { bridgeApiKey: "managed_bridge-secret" }, }); - const service = new ChatProviderOutboundService({ chatProviderRepository: context.providerRepository }); + const service = new ChatProviderOutboundService({ + chatProviderRepository: context.providerRepository, + chatProviderSecretService: context.secretService, + }); const delivery = await service.deliverReply(fixture); @@ -170,7 +176,10 @@ describe("ChatProviderOutboundService", () => { setup: { command: `node ${JSON.stringify(scriptPath)}` }, secrets: { bridgeToken: "native-secret" }, }); - const service = new ChatProviderOutboundService({ chatProviderRepository: context.providerRepository }); + const service = new ChatProviderOutboundService({ + chatProviderRepository: context.providerRepository, + chatProviderSecretService: context.secretService, + }); const delivery = await service.deliverReply(fixture); @@ -197,6 +206,7 @@ describe("ChatProviderOutboundService", () => { }; const service = new ChatProviderOutboundService({ chatProviderRepository: context.providerRepository, + chatProviderSecretService: context.secretService, adapter, initialBackoffMs: 1_000, now: () => now, @@ -280,6 +290,7 @@ describe("ChatProviderOutboundService", () => { }; const service = new ChatProviderOutboundService({ chatProviderRepository: context.providerRepository, + chatProviderSecretService: context.secretService, adapter, initialBackoffMs: 1_000, now: () => now, @@ -290,9 +301,7 @@ describe("ChatProviderOutboundService", () => { const first = service.processDueRetries(); const second = service.processDueRetries(); - await Promise.resolve(); - - expect(adapter.send).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(adapter.send).toHaveBeenCalledTimes(2)); resolveRetry?.({ externalMessageId: "discord-single-flight" }); const [firstResult, secondResult] = await Promise.all([first, second]); @@ -314,17 +323,20 @@ async function createContext(): Promise<{ projectRepository: ProjectManagementRepository; providerRepository: ChatProviderRepository; conversationRepository: ConnectionChatRepository; + secretService: ChatProviderSecretService; }> { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-chat-provider-outbound-")); tempDirs.push(tempDir); const storage = new AppDbStorage(path.join(tempDir, "app.db")); openStorages.push(storage); + const providerRepository = new ChatProviderRepository(storage); return { tempDir, storage, projectRepository: new ProjectManagementRepository(storage), - providerRepository: new ChatProviderRepository(storage), + providerRepository, conversationRepository: new ConnectionChatRepository(storage), + secretService: createChatProviderSecretFixture(providerRepository), }; } @@ -347,7 +359,7 @@ async function createOutboundFixture( sourceType: "local", sourceRef: path.join(context.tempDir, "fixture-repo"), }); - const connection = context.providerRepository.createConnection({ + const connection = await context.secretService.createConnection({ providerKind: options.providerKind, displayName: "Fixture bridge", bridgeMode: options.bridgeMode, diff --git a/tests/backend/services/chat-provider-secret-service.test.ts b/tests/backend/services/chat-provider-secret-service.test.ts new file mode 100644 index 0000000000..096ab6774d --- /dev/null +++ b/tests/backend/services/chat-provider-secret-service.test.ts @@ -0,0 +1,314 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; +import { ChatProviderRepository } from "../../../src/repositories/chat-provider-repository.js"; +import { ChatProviderSecretService } from "../../../src/services/chat-provider-secret-service.js"; +import type { KeyProvider } from "../../../src/services/credentials/key-provider.js"; + +const storages: AppDbStorage[] = []; +const directories: string[] = []; + +afterEach(async () => { + for (const storage of storages.splice(0).reverse()) storage.close(); + await Promise.all(directories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))); +}); + +describe("ChatProviderSecretService", () => { + it("persists only encrypted envelopes, resolves ephemeral credentials, and rotates with CAS metadata", async () => { + const { storage, repository } = await createRepository(); + const service = new ChatProviderSecretService(repository, createKeyProvider()); + const secret = "connector-canary-that-must-never-be-plaintext"; + + const created = await service.createConnection({ + providerKind: "slack", + displayName: "Encrypted Slack", + bridgeMode: "webhook", + secrets: { signingSecret: secret }, + }); + + const raw = storage.getDatabase().prepare(` + SELECT c.secret_json, s.ciphertext, s.secret_keys_json + FROM chat_provider_connections c + JOIN chat_provider_connection_secrets s ON s.provider_connection_id = c.id + WHERE c.id = ? + `).get(created.id) as { secret_json: string | null; ciphertext: Buffer; secret_keys_json: string }; + expect(raw.secret_json).toBeNull(); + expect(raw.ciphertext.toString("utf8")).not.toContain(secret); + expect(raw.secret_keys_json).toBe('["signingSecret"]'); + expect(JSON.stringify(repository.getConnection(created.id))).not.toContain(secret); + expect((await service.resolveConnection(created.id)).secrets).toEqual({ signingSecret: secret }); + + service.updateVerification(created.id, "verified", { endpoint: "ok", authorization: secret }); + expect(repository.getConnection(created.id)).toMatchObject({ verificationStatus: "verified", secretVersion: 1 }); + expect(repository.getConnection(created.id)?.verificationDetails).toEqual({ endpoint: "ok", authorization: "[REDACTED]" }); + + const rotated = await service.updateConnection(created.id, { secrets: { signingSecret: "rotated-secret" } }); + expect(rotated).toMatchObject({ verificationStatus: "unverified", verificationDetails: null, secretVersion: 2 }); + expect((await service.resolveConnection(created.id)).secrets).toEqual({ signingSecret: "rotated-secret" }); + }); + + it("leaves legacy plaintext intact while key custody is unavailable", async () => { + const { storage, repository } = await createRepository(); + const connection = repository.createConnection({ providerKind: "telegram", displayName: "Legacy", bridgeMode: "webhook" }); + const legacy = '{"botToken":"legacy-token"}'; + storage.getDatabase().prepare("UPDATE chat_provider_connections SET secret_json = ? WHERE id = ?").run(legacy, connection.id); + const unavailable = createKeyProvider(false); + + const result = await new ChatProviderSecretService(repository, unavailable).migrateLegacySecrets(); + + expect(result).toMatchObject({ status: "blocked", migrated: 0, pending: 1 }); + expect(result.reason).toContain("fixture key unavailable"); + expect(readLegacy(storage, connection.id)).toBe(legacy); + expect(repository.getEnvelope(connection.id)).toBeNull(); + }); + + it("creates and clears an envelope in the same CAS transaction as metadata", async () => { + const { repository } = await createRepository(); + const service = new ChatProviderSecretService(repository, createKeyProvider()); + const created = await service.createConnection({ + providerKind: "telegram", + displayName: "Unconfigured connector", + bridgeMode: "webhook", + }); + service.updateVerification(created.id, "verified", { endpoint: "ready" }); + + const configured = await service.updateConnection(created.id, { + displayName: "Configured connector", + setup: { webhookUrl: "https://example.test/telegram" }, + secrets: { botToken: "configured-token" }, + }); + expect(configured).toMatchObject({ + displayName: "Configured connector", + setup: { webhookUrl: "https://example.test/telegram" }, + verificationStatus: "unverified", + secretVersion: 1, + }); + expect(repository.getEnvelope(created.id)).not.toBeNull(); + + const cleared = await service.updateConnection(created.id, { + displayName: "Cleared connector", + secrets: null, + }); + expect(cleared).toMatchObject({ + displayName: "Cleared connector", + verificationStatus: "unverified", + secretVersion: 2, + }); + expect(repository.getEnvelope(created.id)).toBeNull(); + }); + + it("allows only one concurrent metadata and secret update to win the expected-version CAS", async () => { + const { repository } = await createRepository(); + const service = new ChatProviderSecretService(repository, createKeyProvider()); + const connection = await service.createConnection({ + providerKind: "slack", + displayName: "CAS baseline", + secrets: { signingSecret: "baseline-secret" }, + }); + + const results = await Promise.allSettled([ + service.updateConnection(connection.id, { + displayName: "CAS winner A", + secrets: { signingSecret: "secret-a" }, + }), + service.updateConnection(connection.id, { + displayName: "CAS winner B", + secrets: { signingSecret: "secret-b" }, + }), + ]); + + const fulfilled = results.filter((result): result is PromiseFulfilledResult>> => ( + result.status === "fulfilled" + )); + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0].reason).toMatchObject({ name: "ChatProviderConcurrentModificationError" }); + expect(repository.getConnection(connection.id)).toMatchObject({ + displayName: fulfilled[0].value.displayName, + secretVersion: 2, + }); + const resolved = await service.resolveConnection(connection.id); + expect(resolved.secrets).toEqual({ + signingSecret: fulfilled[0].value.displayName === "CAS winner A" ? "secret-a" : "secret-b", + }); + }); + + it("rolls back metadata, verification, version, legacy plaintext, and envelope when envelope creation fails", async () => { + const { storage, repository } = await createRepository(); + const service = new ChatProviderSecretService(repository, createKeyProvider()); + const connection = await service.createConnection({ + providerKind: "slack", + displayName: "Create rollback", + bridgeMode: "webhook", + setup: { eventsUrl: "https://example.test/original" }, + }); + service.updateVerification(connection.id, "verified", { endpoint: "original" }); + storage.getDatabase().prepare("UPDATE chat_provider_connections SET secret_json = ? WHERE id = ?") + .run('{"signingSecret":"legacy-create"}', connection.id); + const before = readAtomicSnapshot(storage, connection.id); + storage.getDatabase().exec(` + CREATE TRIGGER fail_chat_provider_secret_create + BEFORE INSERT ON chat_provider_connection_secrets + BEGIN + SELECT RAISE(ABORT, 'injected envelope creation failure'); + END + `); + + await expect(service.updateConnection(connection.id, { + displayName: "Must roll back", + status: "disabled", + setup: { eventsUrl: "https://example.test/changed" }, + secrets: { signingSecret: "new-create-secret" }, + })).rejects.toThrow("injected envelope creation failure"); + + expect(readAtomicSnapshot(storage, connection.id)).toEqual(before); + }); + + it("rolls back metadata and the existing envelope when envelope replacement fails", async () => { + const { storage, repository } = await createRepository(); + const service = new ChatProviderSecretService(repository, createKeyProvider()); + const connection = await service.createConnection({ + providerKind: "discord", + displayName: "Replace rollback", + bridgeMode: "webhook", + setup: { gatewayUrl: "https://example.test/original" }, + secrets: { botToken: "original-token" }, + }); + service.updateVerification(connection.id, "verified", { endpoint: "original" }); + storage.getDatabase().prepare("UPDATE chat_provider_connections SET secret_json = ? WHERE id = ?") + .run('{"botToken":"legacy-replace"}', connection.id); + const before = readAtomicSnapshot(storage, connection.id); + storage.getDatabase().exec(` + CREATE TRIGGER fail_chat_provider_secret_replace + BEFORE INSERT ON chat_provider_connection_secrets + BEGIN + SELECT RAISE(ABORT, 'injected envelope replacement failure'); + END + `); + + await expect(service.updateConnection(connection.id, { + displayName: "Must roll back", + enabled: false, + setup: { gatewayUrl: "https://example.test/changed" }, + secrets: { botToken: "replacement-token" }, + })).rejects.toThrow("injected envelope replacement failure"); + + expect(readAtomicSnapshot(storage, connection.id)).toEqual(before); + expect((await service.resolveConnection(connection.id)).secrets).toEqual({ botToken: "original-token" }); + }); + + it("rolls back metadata and the existing envelope when envelope clearing fails", async () => { + const { storage, repository } = await createRepository(); + const service = new ChatProviderSecretService(repository, createKeyProvider()); + const connection = await service.createConnection({ + providerKind: "telegram", + displayName: "Clear rollback", + bridgeMode: "webhook", + secrets: { botToken: "original-token" }, + }); + service.updateVerification(connection.id, "verified", { endpoint: "original" }); + storage.getDatabase().prepare("UPDATE chat_provider_connections SET secret_json = ? WHERE id = ?") + .run('{"botToken":"legacy-clear"}', connection.id); + const before = readAtomicSnapshot(storage, connection.id); + storage.getDatabase().exec(` + CREATE TRIGGER fail_chat_provider_secret_clear + BEFORE DELETE ON chat_provider_connection_secrets + BEGIN + SELECT RAISE(ABORT, 'injected envelope clear failure'); + END + `); + + await expect(service.updateConnection(connection.id, { + displayName: "Must roll back", + secrets: null, + })).rejects.toThrow("injected envelope clear failure"); + + expect(readAtomicSnapshot(storage, connection.id)).toEqual(before); + expect((await service.resolveConnection(connection.id)).secrets).toEqual({ botToken: "original-token" }); + }); + + it("commits each legacy seal atomically, resumes after a partial failure, and is idempotent", async () => { + const { storage, repository } = await createRepository(); + const first = repository.createConnection({ providerKind: "discord", displayName: "Legacy one" }); + const second = repository.createConnection({ providerKind: "discord", displayName: "Legacy two" }); + storage.getDatabase().prepare("UPDATE chat_provider_connections SET secret_json = ? WHERE id = ?") + .run('{"botToken":"first-secret"}', first.id); + storage.getDatabase().prepare("UPDATE chat_provider_connections SET secret_json = ? WHERE id = ?") + .run('{"botToken":"second-secret"}', second.id); + let activeReads = 0; + let failSecond = true; + const provider = createKeyProvider(true, () => { + activeReads += 1; + if (failSecond && activeReads === 2) throw new Error("fixture encryption interruption"); + }); + const service = new ChatProviderSecretService(repository, provider); + + const partial = await service.migrateLegacySecrets(); + expect(partial).toMatchObject({ status: "partial", migrated: 1, pending: 1 }); + expect(readLegacy(storage, first.id)).toBeNull(); + expect(readLegacy(storage, second.id)).toBe('{"botToken":"second-secret"}'); + expect(repository.getEnvelope(first.id)).not.toBeNull(); + expect(repository.getEnvelope(second.id)).toBeNull(); + + failSecond = false; + const resumed = await service.migrateLegacySecrets(); + expect(resumed).toMatchObject({ status: "ready", migrated: 1, pending: 0 }); + expect(readLegacy(storage, second.id)).toBeNull(); + expect((await service.resolveConnection(second.id)).secrets).toEqual({ botToken: "second-secret" }); + await expect(service.migrateLegacySecrets()).resolves.toEqual({ status: "ready", migrated: 0, pending: 0, failures: [] }); + }); +}); + +async function createRepository(): Promise<{ storage: AppDbStorage; repository: ChatProviderRepository }> { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-chat-secret-")); + directories.push(directory); + const storage = new AppDbStorage(path.join(directory, "app.db")); + storages.push(storage); + return { storage, repository: new ChatProviderRepository(storage) }; +} + +function createKeyProvider(available = true, beforeActiveRead?: () => void): KeyProvider { + const rootKey = Buffer.alloc(32, 17); + return { + providerName: "fixture-key-provider", + health: async () => ({ + available, + secure: true, + provider: "fixture-key-provider", + keyId: available ? "fixture-root" : null, + keyVersion: available ? 1 : null, + reason: available ? undefined : "fixture key unavailable", + }), + getActiveKey: async () => { + beforeActiveRead?.(); + return { key: Buffer.from(rootKey), keyId: "fixture-root", version: 1 }; + }, + getKey: async () => ({ key: Buffer.from(rootKey), keyId: "fixture-root", version: 1 }), + }; +} + +function readLegacy(storage: AppDbStorage, connectionId: string): string | null { + const row = storage.getDatabase().prepare("SELECT secret_json FROM chat_provider_connections WHERE id = ?") + .get(connectionId) as { secret_json: string | null }; + return row.secret_json; +} + +function readAtomicSnapshot(storage: AppDbStorage, connectionId: string): Record { + const connection = storage.getDatabase().prepare(` + SELECT display_name, bridge_mode, status, enabled, setup_json, secret_json, + verification_status, verification_details_json, verified_at, secret_version, updated_at + FROM chat_provider_connections + WHERE id = ? + `).get(connectionId) as Record; + const envelope = storage.getDatabase().prepare(` + SELECT hex(ciphertext) AS ciphertext, hex(nonce) AS nonce, hex(auth_tag) AS auth_tag, + hex(wrapped_data_key) AS wrapped_data_key, hex(wrap_nonce) AS wrap_nonce, + hex(wrap_auth_tag) AS wrap_auth_tag, key_id, key_version, secret_keys_json, updated_at + FROM chat_provider_connection_secrets + WHERE provider_connection_id = ? + `).get(connectionId) as Record | undefined; + return { connection, envelope: envelope ?? null }; +} diff --git a/tests/backend/services/discord-gateway-session.test.ts b/tests/backend/services/discord-gateway-session.test.ts deleted file mode 100644 index 98699c23cb..0000000000 --- a/tests/backend/services/discord-gateway-session.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - DiscordGatewaySession, - DiscordGatewaySessionError, - type DiscordGatewayConnection, - type DiscordGatewaySessionState, - type DiscordGatewaySessionStore, - type DiscordGatewayTransport, - type DiscordGatewayTransportHandlers, -} from "../../../src/services/chat-providers/discord-gateway-session.js"; - -describe("DiscordGatewaySession", () => { - it("identifies, tracks sequence state, delivers messages, and suppresses its own bot loop", async () => { - const harness = createHarness(); - const running = harness.session.start(); - await harness.connected(0); - await harness.emit(0, { op: 10, d: { heartbeat_interval: 45_000 } }); - expect(harness.sent(0)).toContainEqual(expect.objectContaining({ op: 2, d: expect.objectContaining({ token: "bot-token", intents: 37_377 }) })); - - await harness.emit(0, ready(10)); - expect(harness.store.value).toEqual({ - sessionId: "session-1", - resumeGatewayUrl: "wss://gateway.discord.gg/?v=10&encoding=json", - sequence: 10, - botUserId: "999999999999999999", - }); - expect(JSON.stringify(harness.store.value)).not.toContain("bot-token"); - - await harness.emit(0, message(11, "333333333333333333")); - await harness.emit(0, message(12, "999999999999999999")); - expect(harness.onMessage).toHaveBeenCalledTimes(1); - expect(harness.onMessage).toHaveBeenCalledWith(expect.objectContaining({ - normalized: expect.objectContaining({ - externalMessageId: "111111111111111111", - externalSenderId: "333333333333333333", - }), - })); - expect(harness.store.value?.sequence).toBe(12); - - await harness.session.stop(); - await running; - expect(harness.transport.connections[0]?.closed).toContainEqual([1000, "Code UX shutdown"]); - expect(harness.store.value).toBeNull(); - }); - - it("resumes a persisted session after a simulated disconnect", async () => { - const harness = createHarness({ - sessionId: "stored-session", - resumeGatewayUrl: "wss://gateway.discord.gg", - sequence: 77, - botUserId: "999999999999999999", - }); - const running = harness.session.start(); - await harness.connected(0); - expect(harness.transport.urls[0]).toBe("wss://gateway.discord.gg/?v=10&encoding=json"); - await harness.emit(0, { op: 10, d: { heartbeat_interval: 45_000 } }); - expect(harness.sent(0)).toContainEqual({ - op: 6, - d: { token: "bot-token", session_id: "stored-session", seq: 77 }, - }); - await harness.close(0, 4000); - await harness.connected(1); - await harness.emit(1, { op: 10, d: { heartbeat_interval: 45_000 } }); - expect(harness.sent(1)).toContainEqual({ - op: 6, - d: { token: "bot-token", session_id: "stored-session", seq: 77 }, - }); - expect(harness.wait).toHaveBeenCalledWith(100, expect.any(AbortSignal)); - - await harness.session.stop(); - await running; - }); - - it("falls back to Identify when Discord invalidates resumable state", async () => { - const harness = createHarness({ - sessionId: "stored-session", - resumeGatewayUrl: "wss://gateway.discord.gg", - sequence: 77, - }); - const running = harness.session.start(); - await harness.connected(0); - await harness.emit(0, { op: 10, d: { heartbeat_interval: 45_000 } }); - await harness.emit(0, { op: 9, d: false }); - await harness.connected(1); - await harness.emit(1, { op: 10, d: { heartbeat_interval: 45_000 } }); - expect(harness.sent(1)).toContainEqual(expect.objectContaining({ op: 2 })); - expect(harness.sent(1)).not.toContainEqual(expect.objectContaining({ op: 6 })); - - await harness.session.stop(); - await running; - }); - - it("uses heartbeat jitter, records ACKs, and reconnects after a missed ACK", async () => { - const harness = createHarness(null, { random: () => 0.5 }); - const running = harness.session.start(); - await harness.connected(0); - await harness.emit(0, { op: 10, d: { heartbeat_interval: 1_000 } }); - expect(harness.timers.delays).toEqual([500]); - - await harness.timers.fireNext(); - expect(harness.sent(0)).toContainEqual({ op: 1, d: null }); - expect(harness.timers.delays).toEqual([1_000]); - await harness.timers.fireNext(); - await harness.connected(1); - expect(harness.transport.connections[0]?.closed).toContainEqual([4000, "Missed heartbeat ACK"]); - - await harness.session.stop(); - await running; - }); - - it("continues heartbeats when ACKs arrive", async () => { - const harness = createHarness(null, { random: () => 0 }); - const running = harness.session.start(); - await harness.connected(0); - await harness.emit(0, { op: 10, d: { heartbeat_interval: 1_000 } }); - await harness.timers.fireNext(); - await harness.emit(0, { op: 11 }); - await harness.timers.fireNext(); - expect(harness.sent(0).filter((payload) => payload.op === 1)).toHaveLength(2); - expect(harness.transport.connections).toHaveLength(1); - - await harness.session.stop(); - await running; - }); - - it("classifies privileged intent failures without token disclosure", async () => { - const harness = createHarness(); - const running = harness.session.start(); - const rejection = expect(running).rejects.toMatchObject({ code: "missing_privileged_intent", retryable: false }); - await harness.connected(0); - await harness.close(0, 4014); - await rejection; - expect(harness.onFailure).toHaveBeenCalledWith(expect.objectContaining({ code: "missing_privileged_intent" })); - expect(JSON.stringify(harness.onFailure.mock.calls)).not.toContain("bot-token"); - }); - - it("bounds exponential reconnect backoff", async () => { - const harness = createHarness(null, { maxReconnectAttempts: 3 }); - const running = harness.session.start(); - const rejection = expect(running).rejects.toMatchObject({ code: "reconnect_exhausted" }); - await harness.connected(0); - await harness.close(0, 4000); - await harness.connected(1); - await harness.close(1, 4000); - await harness.connected(2); - await harness.close(2, 4000); - await harness.connected(3); - await harness.close(3, 4000); - await rejection; - expect(harness.wait.mock.calls.map(([delay]) => delay)).toEqual([100, 200, 250]); - }); - - it("stops reconnecting after cancellation or clean shutdown", async () => { - const controller = new AbortController(); - const harness = createHarness(); - const running = harness.session.start(controller.signal); - await harness.connected(0); - controller.abort(); - await running; - await flush(); - expect(harness.transport.connections).toHaveLength(1); - - const clean = createHarness(); - const cleanRunning = clean.session.start(); - await clean.connected(0); - await clean.session.stop(); - await cleanRunning; - expect(clean.transport.connections).toHaveLength(1); - }); - - it("rejects untrusted persisted resume origins", async () => { - const harness = createHarness({ - sessionId: "stored-session", - resumeGatewayUrl: "wss://attacker.example.test/gateway", - sequence: 77, - }); - const running = harness.session.start(); - await harness.connected(0); - expect(harness.transport.urls[0]).toBe("wss://gateway.discord.gg/?v=10&encoding=json"); - await harness.emit(0, { op: 10, d: { heartbeat_interval: 45_000 } }); - expect(harness.sent(0)).toContainEqual(expect.objectContaining({ op: 2 })); - await harness.session.stop(); - await running; - }); -}); - -interface FakeConnection extends DiscordGatewayConnection { - sent: string[]; - closed: Array<[number | undefined, string | undefined]>; -} - -class FakeTransport implements DiscordGatewayTransport { - readonly connections: FakeConnection[] = []; - readonly handlers: DiscordGatewayTransportHandlers[] = []; - readonly urls: string[] = []; - - async connect(url: string, handlers: DiscordGatewayTransportHandlers): Promise { - this.urls.push(url); - this.handlers.push(handlers); - const connection: FakeConnection = { - sent: [], - closed: [], - send: (payload) => { connection.sent.push(payload); }, - close: (code, reason) => { connection.closed.push([code, reason]); }, - }; - this.connections.push(connection); - return connection; - } -} - -class MemoryStore implements DiscordGatewaySessionStore { - value: DiscordGatewaySessionState | null; - constructor(initial: DiscordGatewaySessionState | null) { this.value = initial; } - async load(): Promise { return this.value ? { ...this.value } : null; } - async save(_connectionId: string, value: DiscordGatewaySessionState): Promise { this.value = { ...value }; } - async clear(): Promise { this.value = null; } -} - -function createTimers() { - const callbacks: Array<() => void> = []; - const delays: number[] = []; - return { - callbacks, - delays, - setTimer(callback: () => void, delay: number) { - callbacks.push(callback); - delays.push(delay); - return callback; - }, - clearTimer(timer: unknown) { - const index = callbacks.indexOf(timer as () => void); - if (index >= 0) callbacks.splice(index, 1); - }, - async fireNext() { - const callback = callbacks.shift(); - delays.shift(); - if (!callback) throw new Error("No scheduled timer"); - callback(); - await flush(); - }, - }; -} - -function createHarness( - initial: DiscordGatewaySessionState | null = null, - overrides: Partial[0]> = {}, -) { - const transport = new FakeTransport(); - const store = new MemoryStore(initial); - const timers = createTimers(); - const onMessage = vi.fn(async () => undefined); - const onFailure = vi.fn(async () => undefined); - const wait = vi.fn(async () => undefined); - const session = new DiscordGatewaySession({ - connectionId: "connection-1", - botToken: "bot-token", - intents: 37_377, - transport, - sessionStore: store, - onMessage, - onFailure, - wait, - setTimer: timers.setTimer, - clearTimer: timers.clearTimer, - initialBackoffMs: 100, - maxBackoffMs: 250, - ...overrides, - }); - return { - session, - transport, - store, - timers, - onMessage, - onFailure, - wait, - async connected(index: number) { - await until(() => transport.connections.length > index); - }, - async emit(index: number, payload: unknown) { - await transport.handlers[index]?.onMessage(JSON.stringify(payload)); - await flush(); - }, - async close(index: number, code: number) { - await transport.handlers[index]?.onClose(code); - await flush(); - }, - sent(index: number): Array> { - return (transport.connections[index]?.sent ?? []).map((payload) => JSON.parse(payload)); - }, - }; -} - -function ready(sequence: number) { - return { - op: 0, - t: "READY", - s: sequence, - d: { - session_id: "session-1", - resume_gateway_url: "wss://gateway.discord.gg", - user: { id: "999999999999999999" }, - }, - }; -} - -function message(sequence: number, authorId: string) { - return { - op: 0, - t: "MESSAGE_CREATE", - s: sequence, - d: { - id: "111111111111111111", - channel_id: "222222222222222222", - content: "Investigate the failure", - author: { id: authorId, username: "alex" }, - }, - }; -} - -async function until(predicate: () => boolean): Promise { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (predicate()) return; - await flush(); - } - throw new DiscordGatewaySessionError("transport_failure", "Test transport did not connect.", false); -} - -async function flush(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)); -} - diff --git a/tests/backend/services/microsoft-bot-auth.test.ts b/tests/backend/services/microsoft-bot-auth.test.ts deleted file mode 100644 index 499000f083..0000000000 --- a/tests/backend/services/microsoft-bot-auth.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { - createSign, - generateKeyPairSync, - type KeyObject, -} from "node:crypto"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { - MICROSOFT_BOT_ISSUER, - MICROSOFT_BOT_JWKS_URL, - MICROSOFT_BOT_OPENID_METADATA_URL, - MICROSOFT_BOT_TOKEN_SCOPE, - MicrosoftBotAuthError, - MicrosoftBotAuthService, - isAllowedMicrosoftBotServiceUrl, - type MicrosoftBotCredentials, -} from "../../../src/services/chat-providers/microsoft-bot-auth.js"; - -const NOW = new Date("2026-07-13T12:00:00.000Z"); -const NOW_SECONDS = Math.floor(NOW.getTime() / 1000); -const SERVICE_URL = "https://smba.trafficmanager.net/teams"; - -let privateKey1: KeyObject; -let privateKey2: KeyObject; -let jwk1: Record; -let jwk2: Record; - -beforeAll(() => { - ({ privateKey: privateKey1, jwk: jwk1 } = createSigningFixture("key-1")); - ({ privateKey: privateKey2, jwk: jwk2 } = createSigningFixture("key-2")); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("MicrosoftBotAuthService inbound authentication", () => { - it("validates a signed Connector JWT and returns a durable, secret-free conversation reference", async () => { - const fetchMock = microsoftSigningFetch(() => [jwk1]); - const service = new MicrosoftBotAuthService({ fetch: fetchMock, now: () => NOW }); - const activity = activityFixture(); - const token = signJwt(privateKey1, "key-1", claimsFixture()); - - const result = await service.validateIncomingActivity({ - authorization: `Bearer ${token}`, - activity, - credentials: credentials(), - }); - - expect(result.normalized.textBody).toBe("run the checks"); - expect(result.conversationReference).toMatchObject({ - serviceUrl: SERVICE_URL, - serviceUrlValidated: true, - activityId: "activity-1", - channelId: "msteams", - tenantId: "tenant-1", - teamId: "team-1", - teamsChannelId: "channel-1", - conversation: { id: "conversation-1" }, - bot: { id: "bot-app-id" }, - user: { id: "user-1" }, - }); - expect(result.ingressPayload.microsoftTeamsConversationReference).toEqual(result.conversationReference); - expect(JSON.stringify(result.ingressPayload)).not.toContain(token); - expect(JSON.stringify(result.ingressPayload)).not.toContain("client-secret"); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it.each([ - ["wrong audience", { claims: { aud: "another-app" } }, "jwt_audience_invalid"], - ["wrong issuer", { claims: { iss: "https://issuer.example.test" } }, "jwt_issuer_invalid"], - ["expired JWT", { claims: { exp: NOW_SECONDS - 301 } }, "jwt_expired"], - ["future JWT", { claims: { nbf: NOW_SECONDS + 301 } }, "jwt_not_yet_valid"], - ["wrong algorithm", { header: { alg: "HS256" } }, "jwt_algorithm_invalid"], - ["service URL mismatch", { claims: { serviceUrl: `${SERVICE_URL}/wrong` } }, "service_url_mismatch"], - ["bad signature", { signingKey: "second" }, "jwt_signature_invalid"], - ] as const)("rejects %s", async (_label, mutation, expectedCode) => { - const service = new MicrosoftBotAuthService({ fetch: microsoftSigningFetch(() => [jwk1]), now: () => NOW }); - const claims = { ...claimsFixture(), ...(mutation.claims ?? {}) }; - const token = signJwt( - mutation.signingKey === "second" ? privateKey2 : privateKey1, - "key-1", - claims, - mutation.header, - ); - - await expectAuthCode(service.validateIncomingActivity({ - authorization: `Bearer ${token}`, - activity: activityFixture(), - credentials: credentials(), - }), expectedCode); - }); - - it("rejects a missing channel endorsement", async () => { - const unendorsed = { ...jwk1, endorsements: ["webchat"] }; - const service = new MicrosoftBotAuthService({ fetch: microsoftSigningFetch(() => [unendorsed]), now: () => NOW }); - - await expectAuthCode(service.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey1, "key-1", claimsFixture())}`, - activity: activityFixture(), - credentials: credentials(), - }), "channel_endorsement_missing"); - }); - - it("rejects a wrong tenant and an authenticated but undocumented service URL", async () => { - const service = new MicrosoftBotAuthService({ fetch: microsoftSigningFetch(() => [jwk1]), now: () => NOW }); - await expectAuthCode(service.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey1, "key-1", claimsFixture())}`, - activity: activityFixture(), - credentials: { ...credentials(), tenantId: "tenant-2" }, - }), "tenant_mismatch"); - - const evilUrl = "https://metadata.internal.example/teams"; - const evilActivity = { ...activityFixture(), serviceUrl: evilUrl }; - await expectAuthCode(service.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey1, "key-1", { ...claimsFixture(), serviceUrl: evilUrl })}`, - activity: evilActivity, - credentials: credentials(), - }), "service_url_invalid"); - - const arbitraryBotFrameworkUrl = "https://arbitrary.botframework.com/teams"; - await expectAuthCode(service.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey1, "key-1", { - ...claimsFixture(), - serviceUrl: arbitraryBotFrameworkUrl, - })}`, - activity: { ...activityFixture(), serviceUrl: arbitraryBotFrameworkUrl }, - credentials: credentials(), - }), "service_url_invalid"); - }); - - it("refreshes once for key rotation, bounds unknown-key refreshes, and refreshes expired caches", async () => { - let now = new Date(NOW); - let keys = [jwk1]; - const fetchMock = microsoftSigningFetch(() => keys); - const service = new MicrosoftBotAuthService({ - fetch: fetchMock, - now: () => now, - signingKeyCacheMs: 60 * 60 * 1000, - unknownKeyRefreshIntervalMs: 5 * 60 * 1000, - }); - - await service.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey1, "key-1", claimsFixture())}`, - activity: activityFixture(), - credentials: credentials(), - }); - expect(fetchMock).toHaveBeenCalledTimes(2); - - keys = [jwk2]; - await service.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey2, "key-2", claimsFixture())}`, - activity: activityFixture(), - credentials: credentials(), - }); - expect(fetchMock).toHaveBeenCalledTimes(4); - - now = new Date(NOW.getTime() + 5 * 60 * 1000); - const unknownToken = signJwt(privateKey2, "unknown-key", claimsFixture()); - await expectAuthCode(service.validateIncomingActivity({ - authorization: `Bearer ${unknownToken}`, - activity: activityFixture(), - credentials: credentials(), - }), "signing_key_unknown"); - await expectAuthCode(service.validateIncomingActivity({ - authorization: `Bearer ${unknownToken}`, - activity: activityFixture(), - credentials: credentials(), - }), "signing_key_unknown"); - expect(fetchMock).toHaveBeenCalledTimes(6); - - now = new Date(NOW.getTime() + 65 * 60 * 1000 + 1); - await service.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey2, "key-2", claimsFixture({ exp: NOW_SECONDS + 7200 }))}`, - activity: activityFixture(), - credentials: credentials(), - }); - expect(fetchMock).toHaveBeenCalledTimes(8); - }); - - it("rejects expired signing keys with a deterministic diagnostic code", async () => { - const expiredKey = { ...jwk1, exp: NOW_SECONDS - 301 }; - const service = new MicrosoftBotAuthService({ fetch: microsoftSigningFetch(() => [expiredKey]), now: () => NOW }); - await expectAuthCode(service.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey1, "key-1", claimsFixture())}`, - activity: activityFixture(), - credentials: credentials(), - }), "signing_key_expired"); - }); - - it("fails closed when fixed metadata or JWKS retrieval fails", async () => { - const invalidMetadataFetch = vi.fn(async () => jsonResponse({ - issuer: MICROSOFT_BOT_ISSUER, - jwks_uri: "https://attacker.example/keys", - id_token_signing_alg_values_supported: ["RS256"], - })); - const invalidMetadataService = new MicrosoftBotAuthService({ fetch: invalidMetadataFetch, now: () => NOW }); - await expectAuthCode(invalidMetadataService.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey1, "key-1", claimsFixture())}`, - activity: activityFixture(), - credentials: credentials(), - }), "openid_metadata_failed"); - expect(invalidMetadataFetch).toHaveBeenCalledTimes(1); - - const jwksFetch = vi.fn(async (input) => String(input) === MICROSOFT_BOT_OPENID_METADATA_URL - ? metadataResponse() - : new Response("unavailable", { status: 502 })); - const jwksService = new MicrosoftBotAuthService({ fetch: jwksFetch, now: () => NOW }); - await expectAuthCode(jwksService.validateIncomingActivity({ - authorization: `Bearer ${signJwt(privateKey1, "key-1", claimsFixture())}`, - activity: activityFixture(), - credentials: credentials(), - }), "microsoft_service_unavailable"); - }); -}); - -describe("MicrosoftBotAuthService outbound transport", () => { - it("caches OAuth tokens only until safe pre-expiry", async () => { - let now = new Date(NOW); - let requestCount = 0; - const fetchMock = vi.fn(async () => jsonResponse({ - token_type: "Bearer", - expires_in: 3600, - access_token: `access-${++requestCount}`, - })); - const service = new MicrosoftBotAuthService({ fetch: fetchMock, now: () => now }); - - expect(await service.acquireAccessToken(credentials())).toBe("access-1"); - now = new Date(NOW.getTime() + 54 * 60 * 1000); - expect(await service.acquireAccessToken(credentials())).toBe("access-1"); - now = new Date(NOW.getTime() + 55 * 60 * 1000); - expect(await service.acquireAccessToken(credentials())).toBe("access-2"); - expect(fetchMock).toHaveBeenCalledTimes(2); - const [tokenUrl, init] = fetchMock.mock.calls[0]; - expect(String(tokenUrl)).toBe("https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token"); - expect(String(init?.body)).toContain(`scope=${encodeURIComponent(MICROSOFT_BOT_TOKEN_SCOPE)}`); - }); - - it("sends replies to the validated conversation activity path with a cached bearer token", async () => { - const requests: Array<{ url: string; init?: RequestInit }> = []; - const fetchMock = vi.fn(async (input, init) => { - requests.push({ url: String(input), init }); - if (String(input).includes("login.microsoftonline.com")) { - return jsonResponse({ token_type: "Bearer", expires_in: 3600, access_token: "access-token" }); - } - return jsonResponse({ id: "reply-activity-1" }, 201); - }); - const service = new MicrosoftBotAuthService({ fetch: fetchMock, now: () => NOW }); - - const result = await service.sendReply({ - credentials: credentials(), - conversationReference: conversationReference(), - text: "Build passed.", - correlationId: "correlation-1", - }); - - expect(result).toEqual({ externalMessageId: "reply-activity-1", statusCode: 201 }); - expect(requests[1].url).toBe( - "https://smba.trafficmanager.net/teams/v3/conversations/conversation-1/activities/activity-1", - ); - expect(new Headers(requests[1].init?.headers).get("authorization")).toBe("Bearer access-token"); - expect(JSON.parse(String(requests[1].init?.body))).toMatchObject({ - type: "message", - from: { id: "bot-app-id" }, - recipient: { id: "user-1" }, - conversation: { id: "conversation-1" }, - replyToId: "activity-1", - text: "Build passed.", - channelData: { tenant: { id: "tenant-1" } }, - }); - }); - - it("rejects unvalidated reply references and classifies throttling and unavailable services", async () => { - const replyFetch = vi.fn(); - const service = new MicrosoftBotAuthService({ fetch: replyFetch, now: () => NOW }); - await expectAuthCode(service.sendReply({ - credentials: credentials(), - conversationReference: { ...conversationReference(), serviceUrl: "https://attacker.example" }, - text: "No", - }), "service_url_invalid"); - await expectAuthCode(service.sendReply({ - credentials: credentials(), - conversationReference: { - ...conversationReference(), - serviceUrl: "https://arbitrary.botframework.com/teams", - }, - text: "Still no", - }), "service_url_invalid"); - expect(replyFetch).not.toHaveBeenCalled(); - - const throttled = new MicrosoftBotAuthService({ - fetch: vi.fn(async () => new Response("slow down", { - status: 429, - headers: { "retry-after": "3" }, - })), - now: () => NOW, - }); - try { - await throttled.acquireAccessToken(credentials()); - throw new Error("Expected throttling failure"); - } catch (error) { - expect(error).toMatchObject({ code: "microsoft_throttled", retryable: true, retryAfterMs: 3000 }); - } - - const unavailable = new MicrosoftBotAuthService({ - fetch: vi.fn(async () => new Response("offline", { status: 503 })), - now: () => NOW, - }); - await expectAuthCode(unavailable.acquireAccessToken(credentials()), "microsoft_service_unavailable"); - }); - - it("aborts timed-out Microsoft requests", async () => { - vi.useFakeTimers(); - const fetchMock = vi.fn(async (_input, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); - })); - const service = new MicrosoftBotAuthService({ fetch: fetchMock, now: () => NOW, requestTimeoutMs: 25 }); - const request = service.acquireAccessToken(credentials()); - const assertion = expectAuthCode(request, "microsoft_service_unavailable"); - - await vi.advanceTimersByTimeAsync(25); - await assertion; - }); - - it("returns deterministic app, token, and signing metadata diagnostics", async () => { - const invalid = new MicrosoftBotAuthService({ fetch: vi.fn(), now: () => NOW }); - const invalidResult = await invalid.diagnoseConnection({ ...credentials(), microsoftAppId: "" }); - expect(invalidResult).toEqual({ - ok: false, - checks: [{ - check: "app_identity", - ok: false, - code: "app_identity_invalid", - message: "Microsoft app ID is required.", - retryable: false, - }], - }); - - const diagnosticFetch = vi.fn(async (input) => { - if (String(input).includes("login.microsoftonline.com")) { - return jsonResponse({ token_type: "Bearer", expires_in: 3600, access_token: "token" }); - } - if (String(input) === MICROSOFT_BOT_OPENID_METADATA_URL) { - return metadataResponse(); - } - return jsonResponse({ keys: [jwk1] }); - }); - const healthy = new MicrosoftBotAuthService({ fetch: diagnosticFetch, now: () => NOW }); - const result = await healthy.diagnoseConnection(credentials()); - expect(result.ok).toBe(true); - expect(result.checks.map((check) => [check.check, check.code])).toEqual([ - ["app_identity", "ok"], - ["token_acquisition", "ok"], - ["signing_metadata", "ok"], - ]); - }); - - it.each([ - [ - "expired", - () => [{ ...jwk1, exp: NOW_SECONDS - 301 }], - "signing_key_expired", - "published no currently active signing keys", - ], - [ - "otherwise unusable", - () => [{ - kid: "unusable-key", - kty: "RSA", - alg: "RS256", - use: "sig", - key_ops: ["verify"], - endorsements: ["msteams"], - }], - "signing_keys_unusable", - "published no usable Microsoft Teams signing keys", - ], - ] as const)("reports %s signing-key metadata", async (_label, signingKeys, code, message) => { - const diagnosticFetch = vi.fn(async (input) => { - if (String(input).includes("login.microsoftonline.com")) { - return jsonResponse({ token_type: "Bearer", expires_in: 3600, access_token: "token" }); - } - if (String(input) === MICROSOFT_BOT_OPENID_METADATA_URL) { - return metadataResponse(); - } - return jsonResponse({ keys: signingKeys() }); - }); - const service = new MicrosoftBotAuthService({ fetch: diagnosticFetch, now: () => NOW }); - - const result = await service.diagnoseConnection(credentials()); - - expect(result.ok).toBe(false); - expect(result.checks[2]).toMatchObject({ - check: "signing_metadata", - ok: false, - code, - message: expect.stringContaining(message), - retryable: true, - }); - }); -}); - -describe("Microsoft Bot service URL policy", () => { - it("allows documented Bot Framework hosts and rejects client-supplied or local URLs", () => { - expect(isAllowedMicrosoftBotServiceUrl(SERVICE_URL)).toBe(true); - expect(isAllowedMicrosoftBotServiceUrl("https://smba.infra.gcc.teams.microsoft.com/teams")).toBe(true); - expect(isAllowedMicrosoftBotServiceUrl("https://smba.infra.gov.teams.microsoft.us/teams")).toBe(true); - expect(isAllowedMicrosoftBotServiceUrl("https://smba.infra.dod.teams.microsoft.us/teams")).toBe(true); - expect(isAllowedMicrosoftBotServiceUrl("https://msteams.botframework.com/amer")).toBe(false); - expect(isAllowedMicrosoftBotServiceUrl("https://arbitrary.botframework.com/teams")).toBe(false); - expect(isAllowedMicrosoftBotServiceUrl("http://localhost:3978")).toBe(false); - expect(isAllowedMicrosoftBotServiceUrl("https://smba.trafficmanager.net.evil.example/teams")).toBe(false); - expect(isAllowedMicrosoftBotServiceUrl("https://smba.trafficmanager.net:8443/teams")).toBe(false); - }); -}); - -function createSigningFixture(kid: string): { - privateKey: KeyObject; - jwk: Record; -} { - const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); - return { - privateKey, - jwk: { - ...publicKey.export({ format: "jwk" }), - kid, - alg: "RS256", - use: "sig", - key_ops: ["verify"], - endorsements: ["msteams"], - }, - }; -} - -function signJwt( - privateKey: KeyObject, - kid: string, - claims: Record, - headerOverrides: Record = {}, -): string { - const header = encodeJson({ typ: "JWT", alg: "RS256", kid, ...headerOverrides }); - const payload = encodeJson(claims); - const signingInput = `${header}.${payload}`; - const signer = createSign("RSA-SHA256"); - signer.update(signingInput); - signer.end(); - return `${signingInput}.${signer.sign(privateKey).toString("base64url")}`; -} - -function encodeJson(value: Record): string { - return Buffer.from(JSON.stringify(value)).toString("base64url"); -} - -function claimsFixture(overrides: Record = {}): Record { - return { - iss: MICROSOFT_BOT_ISSUER, - aud: "bot-app-id", - nbf: NOW_SECONDS - 60, - iat: NOW_SECONDS - 60, - exp: NOW_SECONDS + 3600, - serviceUrl: SERVICE_URL, - ...overrides, - }; -} - -function activityFixture(): Record { - return { - type: "message", - id: "activity-1", - serviceUrl: SERVICE_URL, - channelId: "msteams", - locale: "en-US", - timestamp: NOW.toISOString(), - from: { id: "user-1", aadObjectId: "aad-user-1", name: "Taylor" }, - recipient: { id: "bot-app-id", name: "Code UX" }, - conversation: { id: "conversation-1", name: "Engineering", conversationType: "channel", isGroup: true }, - channelData: { - tenant: { id: "tenant-1" }, - team: { id: "team-1" }, - channel: { id: "channel-1", name: "Engineering" }, - }, - text: "Code UX run the checks", - entities: [{ - type: "mention", - text: "Code UX", - mentioned: { id: "bot-app-id", name: "Code UX" }, - }], - }; -} - -function credentials(): MicrosoftBotCredentials { - return { - microsoftAppId: "bot-app-id", - applicationType: "SingleTenant", - tenantId: "tenant-1", - clientSecret: "client-secret", - }; -} - -function conversationReference() { - return { - activityId: "activity-1", - serviceUrl: SERVICE_URL, - serviceUrlValidated: true as const, - channelId: "msteams", - locale: "en-US", - tenantId: "tenant-1", - teamId: "team-1", - teamsChannelId: "channel-1", - conversation: { id: "conversation-1", name: "Engineering", conversationType: "channel", isGroup: true }, - bot: { id: "bot-app-id", name: "Code UX" }, - user: { id: "user-1", name: "Taylor" }, - }; -} - -function microsoftSigningFetch(keys: () => Record[]): ReturnType> { - return vi.fn(async (input) => { - if (String(input) === MICROSOFT_BOT_OPENID_METADATA_URL) { - return metadataResponse(); - } - if (String(input) === MICROSOFT_BOT_JWKS_URL) { - return jsonResponse({ keys: keys() }); - } - throw new Error(`Unexpected request: ${String(input)}`); - }); -} - -function metadataResponse(): Response { - return jsonResponse({ - issuer: MICROSOFT_BOT_ISSUER, - jwks_uri: MICROSOFT_BOT_JWKS_URL, - id_token_signing_alg_values_supported: ["RS256"], - }); -} - -function jsonResponse(body: Record, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -async function expectAuthCode(promise: Promise, code: string): Promise { - try { - await promise; - throw new Error(`Expected MicrosoftBotAuthError with code ${code}`); - } catch (error) { - expect(error).toBeInstanceOf(MicrosoftBotAuthError); - expect(error).toMatchObject({ code }); - } -}