From ed64e1ed52a6a8f59b0dd8dc1d35a4d25a4e9620 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:13:20 +0000 Subject: [PATCH 1/3] feat(task T08): implement via codex --- .../architecture/external-chat-providers.md | 23 +- .../architecture-external-chat-providers.mdx | 23 +- .../docs/operations-credential-security.mdx | 2 + docs-web/operations/credential-security.md | 2 + docs/architecture/external-chat-providers.md | 23 +- docs/operations/credential-security.md | 2 + src/app/dependency-factory/core-factory.ts | 18 + .../dependency-factory/dashboard-factory.ts | 6 + .../lifecycle/dashboard-lifecycle-service.ts | 3 + src/contracts/chat-provider-types.ts | 58 ++ .../security/encrypted-sqlite-secret-store.ts | 7 +- src/mcp/management-tool-handler.ts | 4 +- src/mcp/management/chat-provider-actions.ts | 25 +- src/repositories/chat-provider-repository.ts | 595 ++++++++++++++++-- src/repositories/db/app-db-migrations.ts | 56 +- src/repositories/db/app-db-schema.ts | 53 +- src/server/chat-provider-ingress-routes.ts | 9 +- src/server/chat-provider-routes.ts | 19 +- src/server/code-ux-server.ts | 4 + src/server/dashboard-server.ts | 2 + src/services/chat-provider-ingress-service.ts | 6 +- .../chat-provider-outbound-service.ts | 63 +- src/services/chat-provider-secret-service.ts | 200 ++++++ src/services/chat-provider-security.ts | 21 +- .../helpers/chat-provider-secret-fixture.ts | 14 + .../management-chat-provider-actions.test.ts | 21 +- .../chat-provider-repository.test.ts | 184 +++++- .../chat-provider-ingress-routes.test.ts | 15 +- .../server/chat-provider-routes.test.ts | 3 + .../chat-provider-ingress-service.test.ts | 3 - .../chat-provider-outbound-service.test.ts | 28 +- .../chat-provider-secret-service.test.ts | 131 ++++ 32 files changed, 1468 insertions(+), 155 deletions(-) create mode 100644 src/services/chat-provider-secret-service.ts create mode 100644 tests/backend/helpers/chat-provider-secret-fixture.ts create mode 100644 tests/backend/services/chat-provider-secret-service.test.ts diff --git a/docs-web/architecture/external-chat-providers.md b/docs-web/architecture/external-chat-providers.md index f6662a1dcd..cfd9d0e6d4 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, clearing, and 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..ae8ee992af 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, clearing, and 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..5592d80af7 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 committing metadata, 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..5592d80af7 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 committing metadata, 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..f1b45c9567 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, clearing, and 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..295e7a3b87 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 committing metadata, 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/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/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..8a1251226b 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,54 @@ 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; +} + +export class ChatProviderConcurrentModificationError extends Error { + constructor(message: string) { + super(message); + this.name = "ChatProviderConcurrentModificationError"; + } +} + export interface ListChatProviderConnectionsOptions { providerKind?: ChatProviderKind; enabledOnly?: boolean; @@ -146,34 +210,64 @@ 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 @@ -183,7 +277,11 @@ export class ChatProviderRepository { const setup = input.setup !== undefined ? this.sanitizeSetup(providerKind, input.setup) : existing.setup; - const secrets = input.secrets !== undefined ? input.secrets : existing.secrets; + const setupChanged = this.stringifyJson(setup) !== this.stringifyJson(existing.setup); + const transportChanged = bridgeMode !== existing.bridgeMode + || setupChanged + || (input.enabled !== undefined && input.enabled !== existing.enabled) + || (input.status !== undefined && status !== existing.status); const now = new Date().toISOString(); this.db.prepare(` @@ -194,7 +292,9 @@ 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( @@ -203,11 +303,12 @@ export class ChatProviderRepository { status, input.enabled !== undefined ? (input.enabled ? 1 : 0) : (existing.enabled ? 1 : 0), this.stringifyJson(setup), - this.stringifyNullableJson(secrets), + transportChanged ? 1 : 0, + transportChanged ? 1 : 0, + transportChanged ? 1 : 0, now, connectionId, ); - return this.requireConnection(connectionId); } @@ -233,10 +334,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 +348,117 @@ 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 { + if (envelope.credentialId !== connectionId) { + throw new ValidationError("Connector secret envelope id does not match its connection metadata."); + } + return this.db.transaction(() => { + const now = new Date().toISOString(); + const update = this.db.prepare(` + UPDATE chat_provider_connections + SET secret_version = secret_version + 1, + verification_status = 'unverified', verification_details_json = NULL, verified_at = NULL, + secret_json = NULL, updated_at = ? + WHERE id = ? AND secret_version = ? + `).run(now, connectionId, expectedVersion); + if (update.changes !== 1) { + throw new ChatProviderConcurrentModificationError("Connector secrets changed concurrently; retry the operation."); + } + this.putEnvelope(envelope, secretKeys); + return this.requireConnection(connectionId); + }); + } + + clearConnectionSecrets(connectionId: string, expectedVersion: number): ChatProviderConnectionRecord { + return this.db.transaction(() => { + const update = this.db.prepare(` + UPDATE chat_provider_connections + SET secret_version = secret_version + 1, + verification_status = 'unverified', verification_details_json = NULL, verified_at = NULL, + secret_json = NULL, updated_at = ? + WHERE id = ? AND secret_version = ? + `).run(new Date().toISOString(), connectionId, expectedVersion); + if (update.changes !== 1) { + throw new ChatProviderConcurrentModificationError("Connector secrets changed concurrently; retry the operation."); + } + this.db.prepare("DELETE FROM chat_provider_connection_secrets WHERE provider_connection_id = ?").run(connectionId); + return this.requireConnection(connectionId); + }); + } + + 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 +610,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 +633,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 +649,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 +679,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 +693,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 +715,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 +731,7 @@ export class ChatProviderRepository { input.conversationThreadId ?? null, conversationMessageId, this.stringifyNullableJson(input.payload ?? null), + input.nextAttemptAt ?? null, now, now, ); @@ -532,6 +752,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 +765,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 +815,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 +854,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 +1073,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 +1119,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 +1128,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 +1140,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 +1149,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 +1194,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, }; @@ -818,6 +1279,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 +1315,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 +1347,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 9c75df0c43..8f2cf36176 100644 --- a/src/server/chat-provider-ingress-routes.ts +++ b/src/server/chat-provider-ingress-routes.ts @@ -5,25 +5,26 @@ import { HttpRouteError } from "./http-errors.js"; import { requireTrimmedString } from "./request-parsers.js"; import { ChatProviderIngressSecurity, ChatProviderIngressSecurityError } from "../services/chat-provider-security.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), }); 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 ecf094cf0c..b58675ac7c 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; @@ -50,6 +51,7 @@ export interface ChatProviderIngressResult { interface ChatProviderIngressServiceDependencies { chatProviderRepository: ChatProviderRepository; + chatProviderSecretService?: ChatProviderSecretService; chatThreadRuntimeService: ChatThreadRuntimeService; logger?: Logger; } @@ -77,7 +79,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 a4831cf1cb..f9797fdd0b 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, @@ -234,12 +250,16 @@ export class ChatProviderOutboundService { const adapterError = normalizeAdapterError(error); const retryable = adapterError.retryable && attemptCount < this.maxAttempts; const nextAttemptAt = retryable ? this.computeNextAttemptAt(attemptCount).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", { @@ -284,12 +304,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, @@ -372,13 +397,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..e672342807 --- /dev/null +++ b/src/services/chat-provider-secret-service.ts @@ -0,0 +1,200 @@ +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; + this.repository.updateConnection(connectionId, metadata); + if (envelope === undefined) return this.requireConnection(connectionId); + if (envelope === null) return this.repository.clearConnectionSecrets(connectionId, existing.secretVersion); + return this.repository.replaceSecretEnvelope(connectionId, 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 e42b4b0b4a..9e899b207d 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, @@ -145,6 +152,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/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 14976436f4..a5a3e2bb78 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", @@ -145,7 +148,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", @@ -180,7 +183,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", @@ -230,6 +233,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]) => ( @@ -237,6 +241,7 @@ async function startTestServer(): Promise { )); const chatProviderIngressService = new ChatProviderIngressService({ chatProviderRepository, + chatProviderSecretService, chatThreadRuntimeService: { postMessage } as unknown as ChatThreadRuntimeService, }); const app = express(); @@ -247,6 +252,7 @@ async function startTestServer(): Promise { })); registerChatProviderIngressRoutes(app, { chatProviderRepository, + chatProviderSecretService, chatProviderIngressService, } as DashboardDependencies); const server = await new Promise((resolve) => { @@ -263,6 +269,7 @@ async function startTestServer(): Promise { tempDir, storage, chatProviderRepository, + chatProviderSecretService, connectionChatRepository, projectManagementRepository, postMessage, 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 f07a6ad2e2..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({ diff --git a/tests/backend/services/chat-provider-outbound-service.test.ts b/tests/backend/services/chat-provider-outbound-service.test.ts index 481ff41874..16be5d458c 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, @@ -247,6 +257,7 @@ describe("ChatProviderOutboundService", () => { }; const service = new ChatProviderOutboundService({ chatProviderRepository: context.providerRepository, + chatProviderSecretService: context.secretService, adapter, initialBackoffMs: 1_000, now: () => now, @@ -257,9 +268,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]); @@ -281,17 +290,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), }; } @@ -314,7 +326,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..674f73201d --- /dev/null +++ b/tests/backend/services/chat-provider-secret-service.test.ts @@ -0,0 +1,131 @@ +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("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; +} From 143359b7776b66cc1cdeb6b68f98ca8e7dc2342a Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:32:27 +0000 Subject: [PATCH 2/3] fix(task T08): address qa review via codex --- .../architecture/external-chat-providers.md | 2 +- .../architecture-external-chat-providers.mdx | 2 +- .../docs/operations-credential-security.mdx | 2 +- docs-web/operations/credential-security.md | 2 +- docs/architecture/external-chat-providers.md | 2 +- docs/operations/credential-security.md | 2 +- src/repositories/chat-provider-repository.ts | 143 +++++++++----- src/services/chat-provider-secret-service.ts | 12 +- .../chat-provider-secret-service.test.ts | 183 ++++++++++++++++++ 9 files changed, 287 insertions(+), 63 deletions(-) diff --git a/docs-web/architecture/external-chat-providers.md b/docs-web/architecture/external-chat-providers.md index cfd9d0e6d4..7e3c48fe88 100644 --- a/docs-web/architecture/external-chat-providers.md +++ b/docs-web/architecture/external-chat-providers.md @@ -67,7 +67,7 @@ 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. -- Atomic encrypted-envelope create, rotation, clearing, and resumable post-key-readiness sealing of legacy plaintext. +- 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. - Atomic inbound duplicate insertion by `(providerConnectionId, externalMessageId)` and atomic expiring replay-receipt insertion. diff --git a/docs-web/content/docs/architecture-external-chat-providers.mdx b/docs-web/content/docs/architecture-external-chat-providers.mdx index ae8ee992af..6ffd3cc58a 100644 --- a/docs-web/content/docs/architecture-external-chat-providers.mdx +++ b/docs-web/content/docs/architecture-external-chat-providers.mdx @@ -67,7 +67,7 @@ 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. -- Atomic encrypted-envelope create, rotation, clearing, and resumable post-key-readiness sealing of legacy plaintext. +- 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. - Atomic inbound duplicate insertion by `(providerConnectionId, externalMessageId)` and atomic expiring replay-receipt insertion. diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx index 5592d80af7..8fcc635b5b 100644 --- a/docs-web/content/docs/operations-credential-security.mdx +++ b/docs-web/content/docs/operations-credential-security.mdx @@ -25,7 +25,7 @@ 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 committing metadata, 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. +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. diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md index 5592d80af7..8fcc635b5b 100644 --- a/docs-web/operations/credential-security.md +++ b/docs-web/operations/credential-security.md @@ -25,7 +25,7 @@ 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 committing metadata, 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. +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. diff --git a/docs/architecture/external-chat-providers.md b/docs/architecture/external-chat-providers.md index f1b45c9567..455d6f8548 100644 --- a/docs/architecture/external-chat-providers.md +++ b/docs/architecture/external-chat-providers.md @@ -69,7 +69,7 @@ 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. -- Atomic encrypted-envelope create, rotation, clearing, and resumable post-key-readiness sealing of legacy plaintext. +- 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. - Atomic inbound duplicate insertion by `(providerConnectionId, externalMessageId)` and atomic expiring replay-receipt insertion. diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index 295e7a3b87..1889c83843 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -25,7 +25,7 @@ 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 committing metadata, 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. +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. diff --git a/src/repositories/chat-provider-repository.ts b/src/repositories/chat-provider-repository.ts index 8a1251226b..5ea459e145 100644 --- a/src/repositories/chat-provider-repository.ts +++ b/src/repositories/chat-provider-repository.ts @@ -161,6 +161,15 @@ interface ChatProviderSessionRow { 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); @@ -269,19 +278,7 @@ export class ChatProviderRepository { 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 setupChanged = this.stringifyJson(setup) !== this.stringifyJson(existing.setup); - const transportChanged = bridgeMode !== existing.bridgeMode - || setupChanged - || (input.enabled !== undefined && input.enabled !== existing.enabled) - || (input.status !== undefined && status !== existing.status); + const update = this.prepareConnectionUpdate(existing, input); const now = new Date().toISOString(); this.db.prepare(` @@ -298,20 +295,63 @@ export class ChatProviderRepository { 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), - transportChanged ? 1 : 0, - transportChanged ? 1 : 0, - transportChanged ? 1 : 0, + 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; @@ -370,41 +410,11 @@ export class ChatProviderRepository { envelope: StoredSecretEnvelope, secretKeys: string[], ): ChatProviderConnectionRecord { - if (envelope.credentialId !== connectionId) { - throw new ValidationError("Connector secret envelope id does not match its connection metadata."); - } - return this.db.transaction(() => { - const now = new Date().toISOString(); - const update = this.db.prepare(` - UPDATE chat_provider_connections - SET secret_version = secret_version + 1, - verification_status = 'unverified', verification_details_json = NULL, verified_at = NULL, - secret_json = NULL, updated_at = ? - WHERE id = ? AND secret_version = ? - `).run(now, connectionId, expectedVersion); - if (update.changes !== 1) { - throw new ChatProviderConcurrentModificationError("Connector secrets changed concurrently; retry the operation."); - } - this.putEnvelope(envelope, secretKeys); - return this.requireConnection(connectionId); - }); + return this.updateConnectionWithEnvelope(connectionId, {}, expectedVersion, envelope, secretKeys); } clearConnectionSecrets(connectionId: string, expectedVersion: number): ChatProviderConnectionRecord { - return this.db.transaction(() => { - const update = this.db.prepare(` - UPDATE chat_provider_connections - SET secret_version = secret_version + 1, - verification_status = 'unverified', verification_details_json = NULL, verified_at = NULL, - secret_json = NULL, updated_at = ? - WHERE id = ? AND secret_version = ? - `).run(new Date().toISOString(), connectionId, expectedVersion); - if (update.changes !== 1) { - throw new ChatProviderConcurrentModificationError("Connector secrets changed concurrently; retry the operation."); - } - this.db.prepare("DELETE FROM chat_provider_connection_secrets WHERE provider_connection_id = ?").run(connectionId); - return this.requireConnection(connectionId); - }); + return this.updateConnectionWithEnvelope(connectionId, {}, expectedVersion, null, []); } updateVerification( @@ -1255,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; diff --git a/src/services/chat-provider-secret-service.ts b/src/services/chat-provider-secret-service.ts index e672342807..8dc2b9a9f6 100644 --- a/src/services/chat-provider-secret-service.ts +++ b/src/services/chat-provider-secret-service.ts @@ -61,10 +61,14 @@ export class ChatProviderSecretService { } const { secrets: _secrets, ...metadata } = input; - this.repository.updateConnection(connectionId, metadata); - if (envelope === undefined) return this.requireConnection(connectionId); - if (envelope === null) return this.repository.clearConnectionSecrets(connectionId, existing.secretVersion); - return this.repository.replaceSecretEnvelope(connectionId, existing.secretVersion, envelope, secretKeys); + if (envelope === undefined) return this.repository.updateConnection(connectionId, metadata); + return this.repository.updateConnectionWithEnvelope( + connectionId, + metadata, + existing.secretVersion, + envelope, + secretKeys, + ); } async resolveConnection(connectionId: string): Promise { diff --git a/tests/backend/services/chat-provider-secret-service.test.ts b/tests/backend/services/chat-provider-secret-service.test.ts index 674f73201d..096ab6774d 100644 --- a/tests/backend/services/chat-provider-secret-service.test.ts +++ b/tests/backend/services/chat-provider-secret-service.test.ts @@ -64,6 +64,172 @@ describe("ChatProviderSecretService", () => { 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" }); @@ -129,3 +295,20 @@ function readLegacy(storage: AppDbStorage, connectionId: string): string | null .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 }; +} From f253e8dbe14e49b5e1754932ae810c906d7fec04 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 14 Jul 2026 00:58:05 +0000 Subject: [PATCH 3/3] fix(ci): resolve failing checks on task/feature-codux-22-t08-codex-361e6fad-mrjtgr1j --- tests/backend/domain/chat-connectors/slack.test.ts | 5 ++++- .../server/chat-provider-ingress-routes.test.ts | 2 +- .../services/chat-provider-outbound-service.test.ts | 12 ++++++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) 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/server/chat-provider-ingress-routes.test.ts b/tests/backend/server/chat-provider-ingress-routes.test.ts index ca69f6d1b5..9fae94c741 100644 --- a/tests/backend/server/chat-provider-ingress-routes.test.ts +++ b/tests/backend/server/chat-provider-ingress-routes.test.ts @@ -157,7 +157,7 @@ describe("chat provider ingress routes", () => { it("authenticates official Discord interactions and returns PONG through the production ingress boundary", async () => { const context = await startTestServer(); const project = createProject(context, "discord-official-ingress"); - const connection = context.chatProviderRepository.createConnection({ + const connection = await context.chatProviderSecretService.createConnection({ providerKind: "discord", displayName: "Discord official API", bridgeMode: "official_api", diff --git a/tests/backend/services/chat-provider-outbound-service.test.ts b/tests/backend/services/chat-provider-outbound-service.test.ts index 3aca706b84..bcf96d82ea 100644 --- a/tests/backend/services/chat-provider-outbound-service.test.ts +++ b/tests/backend/services/chat-provider-outbound-service.test.ts @@ -209,7 +209,11 @@ describe("ChatProviderOutboundService", () => { })) .mockResolvedValueOnce(new Response(JSON.stringify({ id: "444444444444444444" }), { status: 200 })); const adapter = new ConfiguredChatProviderOutboundAdapter({ fetch: fetchImpl, wait, now: () => 1_000 }); - const service = new ChatProviderOutboundService({ chatProviderRepository: context.providerRepository, adapter }); + const service = new ChatProviderOutboundService({ + chatProviderRepository: context.providerRepository, + chatProviderSecretService: context.secretService, + adapter, + }); const delivery = await service.deliverReply(fixture); @@ -253,7 +257,11 @@ describe("ChatProviderOutboundService", () => { const adapter = new ConfiguredChatProviderOutboundAdapter({ fetch: vi.fn(async () => new Response("server echoed official-bot-token", { status: 403 })), }); - const service = new ChatProviderOutboundService({ chatProviderRepository: context.providerRepository, adapter }); + const service = new ChatProviderOutboundService({ + chatProviderRepository: context.providerRepository, + chatProviderSecretService: context.secretService, + adapter, + }); const delivery = await service.deliverReply(fixture);