From f294bfb0ebb5f2ff3391c6e4dc3a10346fd06d9a Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Fri, 24 Jul 2026 16:07:23 +0800 Subject: [PATCH] feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Forgejo/Gitea/GitLab integration is intended for self-hosted Multica, where Multica can reach a Git instance on the operator's own network. On the managed multi-tenant cloud it adds an SSRF surface (connect validates a user-supplied instance URL from the server) and would store third-party Git tokens for all tenants under one key, while only serving the small subset of users whose instance is publicly reachable. Product decision: offer it on self-host only. - Add an explicit deployment switch MULTICA_VCS_INTEGRATION_ENABLED (default off). Connect, rotate, and webhook now require BOTH the switch on AND a valid MULTICA_VCS_SECRET_KEY — the switch is the product boundary, not key presence alone. When off, connect/rotate return 404 and the webhook returns a bare 404 (no config leak), independent of the frontend. - /api/config exposes vcs_integration_available (mirrors the switch, omitted when false) so the Settings UI hides the whole "Git providers" section on cloud instead of surfacing an operator-only "missing key" hint. - docker-compose.selfhost.yml defaults the switch on; .env.example documents it. - Docs (en/zh) lead with a callout: available on self-hosted Multica only, not Multica Cloud, and clarify "self-hosted" means Multica itself, not just Git. #5006 / #5883 stay in place — the schema and backend capability are retained; this only gates availability. No cloud VCS connection can exist (connect always required the key, which the cloud never set), so nothing needs migrating. Verified: go build/vet + VCS/config handler tests on a fresh migrated DB (incl. a new disabled-deployment 404 test); pnpm typecheck (core + views) and the integrations-tab + core schema/config vitest suites pass. Co-authored-by: multica-agent --- .env.example | 13 +++++-- apps/docs/content/docs/vcs-integration.mdx | 21 +++++++++++- apps/docs/content/docs/vcs-integration.zh.mdx | 16 ++++++++- docker-compose.selfhost.yml | 5 +++ packages/core/api/schemas.ts | 6 ++++ packages/core/config/index.ts | 15 ++++++-- packages/core/platform/auth-initializer.tsx | 2 ++ packages/core/types/vcs.ts | 6 ++++ .../components/integrations-tab.test.tsx | 18 ++++++++++ .../settings/components/integrations-tab.tsx | 15 +++++--- server/cmd/server/router.go | 1 + server/internal/handler/config.go | 10 ++++++ server/internal/handler/handler.go | 10 ++++++ server/internal/handler/vcs.go | 30 ++++++++++++++++ server/internal/handler/vcs_webhook.go | 7 ++++ server/internal/handler/vcs_webhook_test.go | 34 ++++++++++++++++++- 16 files changed, 197 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 4241cce022..a7949ee5ad 100644 --- a/.env.example +++ b/.env.example @@ -270,12 +270,19 @@ GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY= # Self-hosted Git provider integration (Settings → Integrations): Forgejo, Gitea, -# and GitLab. Off until MULTICA_VCS_SECRET_KEY is set — a base64-encoded -# 32-byte key that encrypts each workspace's access token and webhook secret at -# rest. Leave empty to disable. Generate one with: openssl rand -base64 32 +# and GitLab. This is a SELF-HOSTED-MULTICA-ONLY feature (not offered on the +# managed cloud). Two settings gate it, and BOTH are required — connect, +# webhook, and rotate all check both: +# 1. MULTICA_VCS_INTEGRATION_ENABLED=true turns the feature on for this +# deployment. Left unset/false the section is hidden entirely (this is how +# the managed cloud keeps it off). The self-host docker-compose sets it. +# 2. MULTICA_VCS_SECRET_KEY — a base64-encoded 32-byte key that encrypts each +# workspace's access token and webhook secret at rest. Generate one with: +# openssl rand -base64 32 # Unlike GitHub there is no App: each workspace connects its own instance URL # and access token in the UI, and registers the returned webhook URL/secret on # its repo or org webhook settings. +MULTICA_VCS_INTEGRATION_ENABLED= MULTICA_VCS_SECRET_KEY= # Lark / Feishu bot integration (Settings → Integrations "Bind to Lark") diff --git a/apps/docs/content/docs/vcs-integration.mdx b/apps/docs/content/docs/vcs-integration.mdx index 985eb4e594..1232c09e92 100644 --- a/apps/docs/content/docs/vcs-integration.mdx +++ b/apps/docs/content/docs/vcs-integration.mdx @@ -5,6 +5,15 @@ description: Connect a self-hosted Forgejo, Gitea, or GitLab instance per worksp import { Callout } from "fumadocs-ui/components/callout"; + +**Self-hosted Multica only.** This integration is available only when you run +Multica yourself; it is not offered on Multica Cloud. "Self-hosted" here means +**Multica** is self-hosted — typically so it can reach a Git instance on your own +network. A server operator must enable it (`MULTICA_VCS_INTEGRATION_ENABLED=true`) +and set `MULTICA_VCS_SECRET_KEY`; until then the section does not appear in +Settings → Integrations. + + Multica connects to a self-hosted Git provider per workspace: **Forgejo**, **Gitea**, or **GitLab**. Once connected, any pull request (GitLab: merge request) whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when merged with a closing keyword — moves the issue to **Done**. CI for the head commit shows as a checks bar on the card. These providers run alongside GitHub: a workspace can use any combination. @@ -15,7 +24,15 @@ Unlike GitHub, these providers have no "App" model. Each workspace stores its ow ## Prerequisites (server) -Set a base64-encoded 32-byte key so the server can encrypt stored credentials. Without it, the connect form is disabled. +Turn the integration on for this deployment. It is off by default, so the section stays hidden until you set: + +```sh +MULTICA_VCS_INTEGRATION_ENABLED=true +``` + +The official self-hosted `docker compose` file (`docker-compose.selfhost.yml`) sets this for you. + +Then set a base64-encoded 32-byte key so the server can encrypt stored credentials. Without it, the connect form is disabled. ```sh openssl rand -base64 32 @@ -25,6 +42,8 @@ openssl rand -base64 32 MULTICA_VCS_SECRET_KEY= ``` +Both are required: the switch decides whether the feature is offered, and the key encrypts the stored token and webhook secret. Connect, webhook, and rotate all require both. + Set `MULTICA_PUBLIC_URL` to the server's public base URL so Multica can show a ready-to-paste webhook URL. Without it, the UI shows only the webhook path and you prepend your own origin. ## Connect a workspace diff --git a/apps/docs/content/docs/vcs-integration.zh.mdx b/apps/docs/content/docs/vcs-integration.zh.mdx index 6d4b6e742d..9b77499b5c 100644 --- a/apps/docs/content/docs/vcs-integration.zh.mdx +++ b/apps/docs/content/docs/vcs-integration.zh.mdx @@ -5,6 +5,10 @@ description: 为每个工作区连接自托管的 Forgejo、Gitea 或 GitLab 实 import { Callout } from "fumadocs-ui/components/callout"; + +**仅适用于自部署的 Multica。** 此集成只在你**自己部署 Multica** 时可用,Multica 云端不提供。这里的「自托管」指的是 **Multica 本身自部署**——通常是为了让它能访问你自己网络内的 Git 实例。需要服务端运维开启(`MULTICA_VCS_INTEGRATION_ENABLED=true`)并设置 `MULTICA_VCS_SECRET_KEY`;在此之前,设置 → 集成里不会出现该板块。 + + Multica 按工作区连接自托管的 Git 代码托管:**Forgejo**、**Gitea** 或 **GitLab**。连接后,任何在分支名、标题或正文中包含 issue 编号(例如 `MUL-123`)的 Pull Request(GitLab 为 Merge Request)都会**自动关联**到对应的 [issue](/issues),出现在 issue 侧栏的 **Pull requests** 中;当带有关闭关键字的 PR 合并后,issue 会被移动到「完成」。head commit 的 CI 会以检查条的形式显示在卡片上。 这些代码托管与 GitHub 并行工作:一个工作区可任意组合使用。 @@ -15,7 +19,15 @@ Multica 按工作区连接自托管的 Git 代码托管:**Forgejo**、**Gitea* ## 前置条件(服务端) -设置一个 base64 编码的 32 字节密钥,供服务端加密存储的凭据。未设置时,连接表单将不可用。 +先为该部署开启此集成。它默认关闭,未开启时该板块不会出现: + +```sh +MULTICA_VCS_INTEGRATION_ENABLED=true +``` + +官方自部署 `docker compose` 文件(`docker-compose.selfhost.yml`)已为你设置好。 + +再设置一个 base64 编码的 32 字节密钥,供服务端加密存储的凭据。未设置时,连接表单将不可用。 ```sh openssl rand -base64 32 @@ -25,6 +37,8 @@ openssl rand -base64 32 MULTICA_VCS_SECRET_KEY= ``` +两者都必需:开关决定是否提供该功能,密钥用于加密存储的令牌与 Webhook 密钥。连接、Webhook、轮换都要求两者同时满足。 + 设置 `MULTICA_PUBLIC_URL` 为服务端的公网基础地址,Multica 即可直接给出可粘贴的 Webhook 地址;否则界面只显示 Webhook 路径,需自行拼接来源地址。 ## 连接工作区 diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index dcd494a618..f250da46fc 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -119,6 +119,11 @@ services: # in the database, so this single deployment-wide key is all the operator # needs to set here. MULTICA_SLACK_SECRET_KEY: ${MULTICA_SLACK_SECRET_KEY:-} + # Self-hosted Git provider integration (Forgejo / Gitea / GitLab). This + # is a self-host-only feature, so the compose file turns it on by default; + # the managed cloud leaves it unset (off). It still needs a valid + # MULTICA_VCS_SECRET_KEY below to actually work. + MULTICA_VCS_INTEGRATION_ENABLED: ${MULTICA_VCS_INTEGRATION_ENABLED:-true} # VCS integration at-rest encryption key for token-based providers MULTICA_VCS_SECRET_KEY: ${MULTICA_VCS_SECRET_KEY:-} restart: unless-stopped diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index f1a84b8b69..e6eb955141 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -184,6 +184,10 @@ export interface AppConfigResponse { daemon_server_url?: string; daemon_app_url?: string; workspace_creation_disabled?: boolean; + /** Whether this deployment offers the self-hosted Git provider integration + * (self-host only; off on the managed cloud). Absent/false hides the whole + * Settings → Integrations "Git providers" section. */ + vcs_integration_available?: boolean; feature_flags?: Record; server_version?: string; } @@ -334,6 +338,7 @@ export const AppConfigSchema = z.object({ daemon_server_url: OptionalStringSchema, daemon_app_url: OptionalStringSchema, workspace_creation_disabled: BooleanWithDefaultSchema(false).optional(), + vcs_integration_available: BooleanWithDefaultSchema(false).optional(), feature_flags: FeatureFlagsSchema, server_version: OptionalStringSchema, }).loose(); @@ -346,6 +351,7 @@ export const EMPTY_APP_CONFIG: AppConfigResponse = { daemon_server_url: "", daemon_app_url: "", workspace_creation_disabled: false, + vcs_integration_available: false, feature_flags: {}, }; diff --git a/packages/core/config/index.ts b/packages/core/config/index.ts index 53d8a5f893..883eb8e802 100644 --- a/packages/core/config/index.ts +++ b/packages/core/config/index.ts @@ -15,6 +15,11 @@ interface ConfigState { // must be hidden. Defaults to false so unknown / older servers behave like // the managed-cloud case. workspaceCreationDisabled: boolean; + // Self-host-only gate for the Git provider integration (Forgejo / Gitea / + // GitLab). When false the whole Settings → Integrations "Git providers" + // section is hidden. Defaults to false so unknown / older servers and the + // managed cloud (which omits the field) keep it hidden. + vcsIntegrationAvailable: boolean; featureFlags: Record; // The running API build version, surfaced in the Help popover so // self-hosted operators can confirm what's deployed. Empty for dev builds @@ -25,6 +30,7 @@ interface ConfigState { allowSignup: boolean; googleClientId?: string; workspaceCreationDisabled?: boolean; + vcsIntegrationAvailable?: boolean; }) => void; setDaemonConfig: (config: { daemonServerUrl?: string; @@ -42,11 +48,16 @@ export const configStore = createStore((set) => ({ daemonServerUrl: "", daemonAppUrl: "", workspaceCreationDisabled: false, + vcsIntegrationAvailable: false, featureFlags: {}, serverVersion: "", setCdnConfig: ({ cdnDomain, cdnSigned = false }) => set({ cdnDomain, cdnSigned }), - setAuthConfig: ({ allowSignup, googleClientId = "", workspaceCreationDisabled = false }) => - set({ allowSignup, googleClientId, workspaceCreationDisabled }), + setAuthConfig: ({ + allowSignup, + googleClientId = "", + workspaceCreationDisabled = false, + vcsIntegrationAvailable = false, + }) => set({ allowSignup, googleClientId, workspaceCreationDisabled, vcsIntegrationAvailable }), setDaemonConfig: ({ daemonServerUrl = "", daemonAppUrl = "" }) => set({ daemonServerUrl, daemonAppUrl }), setFeatureFlags: (flags = {}) => set({ featureFlags: { ...flags } }), diff --git a/packages/core/platform/auth-initializer.tsx b/packages/core/platform/auth-initializer.tsx index 3e127a0683..8bdf65d423 100644 --- a/packages/core/platform/auth-initializer.tsx +++ b/packages/core/platform/auth-initializer.tsx @@ -62,6 +62,8 @@ export function AuthInitializer({ // Old servers omit this field — treat that as "creation allowed" // (the managed-cloud default) rather than blocking the UI. workspaceCreationDisabled: cfg.workspace_creation_disabled === true, + // Absent/false on the managed cloud and older servers → section hidden. + vcsIntegrationAvailable: cfg.vcs_integration_available === true, }); configStore.getState().setDaemonConfig({ daemonServerUrl: cfg.daemon_server_url, diff --git a/packages/core/types/vcs.ts b/packages/core/types/vcs.ts index 04d7dfaba0..2231831ddb 100644 --- a/packages/core/types/vcs.ts +++ b/packages/core/types/vcs.ts @@ -25,6 +25,12 @@ export interface VCSConnection { export interface ListVCSConnectionsResponse { connections: VCSConnection[]; + /** Whether this deployment offers the integration at all (self-host only; + * off on the managed cloud). When false the section is hidden entirely. + * Older backends omit it; treat as true so the existing self-host UI still + * renders (visibility is also gated by vcs_integration_available on + * /api/config, which is the authoritative deployment signal). */ + available?: boolean; /** Whether the deployment has MULTICA_VCS_SECRET_KEY configured. When false * the connect form is disabled. Older backends omit it; treat as false. */ configured?: boolean; diff --git a/packages/views/settings/components/integrations-tab.test.tsx b/packages/views/settings/components/integrations-tab.test.tsx index 089201df21..7db6ab51fc 100644 --- a/packages/views/settings/components/integrations-tab.test.tsx +++ b/packages/views/settings/components/integrations-tab.test.tsx @@ -63,6 +63,9 @@ describe("Settings IntegrationsTab", () => { queryCallsRef.current = []; composioErrorRef.current = null; configStore.getState().setFeatureFlags({ [COMPOSIO_MCP_APPS_FLAG]: true }); + // Reset the self-host-only VCS gate to its default (hidden) so tests stay + // isolated; individual tests opt in below. + configStore.getState().setAuthConfig({ allowSignup: true, vcsIntegrationAvailable: false }); }); it("hides Composio and disables the toolkits query when the feature flag is off", () => { @@ -89,4 +92,19 @@ describe("Settings IntegrationsTab", () => { expect(screen.queryByTestId("composio-tab")).toBeNull(); }); + + it("hides the Git providers section when the deployment reports it unavailable", () => { + // Default (managed cloud / older server): vcsIntegrationAvailable is false. + renderTab(); + + expect(screen.queryByTestId("vcs-tab")).toBeNull(); + }); + + it("shows the Git providers section on a self-hosted deployment that enables it", () => { + configStore.getState().setAuthConfig({ allowSignup: true, vcsIntegrationAvailable: true }); + + renderTab(); + + expect(screen.getByTestId("vcs-tab")).toBeInTheDocument(); + }); }); diff --git a/packages/views/settings/components/integrations-tab.tsx b/packages/views/settings/components/integrations-tab.tsx index 5b8eaf39f6..03d8025f35 100644 --- a/packages/views/settings/components/integrations-tab.tsx +++ b/packages/views/settings/components/integrations-tab.tsx @@ -7,7 +7,7 @@ import { SlackTab } from "./slack-tab"; import { VCSTab } from "./vcs-tab"; import { ApiError } from "@multica/core/api"; import { composioToolkitsOptions } from "@multica/core/composio"; -import { useFeatureEnabled } from "@multica/core/config"; +import { useConfigStore, useFeatureEnabled } from "@multica/core/config"; import { COMPOSIO_MCP_APPS_FLAG } from "@multica/core/feature-flags"; import { useT } from "../../i18n"; import { SettingsSection, SettingsTab } from "./settings-layout"; @@ -33,6 +33,11 @@ export function IntegrationsTab() { const composioUnconfigured = composioToolkits.error instanceof ApiError && composioToolkits.error.status === 503; + // Self-host-only integration: the managed cloud reports this false (field + // omitted from /api/config), so the whole section — header included — is + // hidden there rather than showing an operator-only "missing key" message. + const vcsAvailable = useConfigStore((s) => s.vcsIntegrationAvailable); + return ( $.page.tabs.integrations)}> $.lark.section_title)}> @@ -46,9 +51,11 @@ export function IntegrationsTab() { $.slack.section_title)}> - $.vcs.section_title)}> - - + {vcsAvailable && ( + $.vcs.section_title)}> + + + )} ); } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index f9ab30ce0a..c48bda7196 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -204,6 +204,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus AllowedEmails: splitAndTrim(os.Getenv("ALLOWED_EMAILS")), AllowedEmailDomains: splitAndTrim(os.Getenv("ALLOWED_EMAIL_DOMAINS")), DisableWorkspaceCreation: os.Getenv("DISABLE_WORKSPACE_CREATION") == "true", + VCSIntegrationEnabled: os.Getenv("MULTICA_VCS_INTEGRATION_ENABLED") == "true", PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MULTICA_PUBLIC_URL")), "/"), TrustedProxies: parseTrustedProxies(os.Getenv("MULTICA_TRUSTED_PROXIES")), CloudRuntimeFleetURL: cloudRuntimeFleetURLFromEnv(), diff --git a/server/internal/handler/config.go b/server/internal/handler/config.go index a0a9c7f91f..58bf5c5719 100644 --- a/server/internal/handler/config.go +++ b/server/internal/handler/config.go @@ -37,6 +37,15 @@ type AppConfig struct { DaemonServerURL string `json:"daemon_server_url,omitempty"` DaemonAppURL string `json:"daemon_app_url,omitempty"` + // VCSIntegrationAvailable mirrors the MULTICA_VCS_INTEGRATION_ENABLED + // deployment switch so the Settings UI can hide the whole self-hosted Git + // provider section on deployments where it is off (the managed cloud), + // instead of rendering it and surfacing an operator-only "missing + // MULTICA_VCS_SECRET_KEY" hint a cloud user cannot resolve. Omitted when + // false so the managed-cloud response keeps its previous shape; the UI + // defaults absent to false (hidden). + VCSIntegrationAvailable bool `json:"vcs_integration_available,omitempty"` + // PostHog public config for the frontend. The key is the same Project // API Key the backend uses; returning it here (instead of baking it // into the frontend bundle via NEXT_PUBLIC_*) means self-hosted @@ -73,6 +82,7 @@ func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) { } config.CdnSigned = h.CFSigner != nil config.DaemonServerURL, config.DaemonAppURL = daemonSetupURLsFromEnv() + config.VCSIntegrationAvailable = h.cfg.VCSIntegrationEnabled config.FeatureFlags = featureflags.EvaluateFrontendPublicFlags(r.Context(), h.FeatureFlags) // Only surface the build version on self-hosted deployments. The managed // cloud is continuously deployed and its users can't choose the build, so diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index a3c9337b9b..4a87c901a1 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -66,6 +66,16 @@ type Config struct { // invitation only. The public /api/config endpoint mirrors this flag so // the UI can hide every "Create workspace" affordance — see #3433. DisableWorkspaceCreation bool + // VCSIntegrationEnabled gates the self-hosted Git provider integration + // (Forgejo / Gitea / GitLab) at the deployment level, independent of whether + // MULTICA_VCS_SECRET_KEY is set. It is the product boundary: the feature is + // intended for self-hosted Multica only (where Multica and the Git instance + // can share a network), and is left off on the managed cloud — connect, + // rotate, and webhook handlers reject when it is false, and /api/config + // omits it so the UI hides the whole section rather than showing a + // "missing key" message a cloud user cannot act on. Populated from + // MULTICA_VCS_INTEGRATION_ENABLED; the self-host compose defaults it on. + VCSIntegrationEnabled bool // PublicURL is the absolute base URL the API is reachable at from the // public internet, with no trailing slash (e.g. "https://multica.ai"). // Used only to build webhook_url responses for autopilot webhook triggers diff --git a/server/internal/handler/vcs.go b/server/internal/handler/vcs.go index d17e9b3c25..0a4cbbe387 100644 --- a/server/internal/handler/vcs.go +++ b/server/internal/handler/vcs.go @@ -44,6 +44,13 @@ type VCSConnectResponse struct { const vcsWebhookPathPrefix = "/api/webhooks/vcs/" +// isVCSAvailable reports whether this deployment offers the self-hosted Git +// provider integration at all. It is the product boundary (self-host only) and +// is independent of isVCSConfigured (whether the encryption key is set): the +// managed cloud leaves it off, so connect/rotate/webhook reject and the UI +// hides the section rather than surfacing an operator-only "missing key" hint. +func (h *Handler) isVCSAvailable() bool { return h.cfg.VCSIntegrationEnabled } + func (h *Handler) isVCSConfigured() bool { return h.VCSSecretBox != nil } func (h *Handler) vcsWebhookPath(connID string) string { return vcsWebhookPathPrefix + connID } @@ -106,6 +113,20 @@ func (h *Handler) ListVCSConnections(w http.ResponseWriter, r *http.Request) { member, _ := middleware.MemberFromContext(r.Context()) canManage := roleAllowed(member.Role, "owner", "admin") + // Deployments where the integration is off (the managed cloud) report + // available=false and nothing else, so the UI hides the whole section + // instead of showing an operator-only "missing key" hint. No connections + // can exist there anyway (connect is rejected), so skip the query. + if !h.isVCSAvailable() { + writeJSON(w, http.StatusOK, map[string]any{ + "connections": []VCSConnectionResponse{}, + "available": false, + "configured": false, + "can_manage": false, + }) + return + } + rows, err := h.Queries.ListVCSConnectionsByWorkspace(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list connections") @@ -117,6 +138,7 @@ func (h *Handler) ListVCSConnections(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{ "connections": out, + "available": true, "configured": h.isVCSConfigured(), "can_manage": canManage, }) @@ -138,6 +160,10 @@ func (h *Handler) ConnectVCS(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !h.isVCSAvailable() { + writeError(w, http.StatusNotFound, "vcs integration is not available on this deployment") + return + } if !h.isVCSConfigured() { writeError(w, http.StatusServiceUnavailable, "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)") return @@ -256,6 +282,10 @@ func (h *Handler) RotateVCSConnectionWebhook(w http.ResponseWriter, r *http.Requ if !ok { return } + if !h.isVCSAvailable() { + writeError(w, http.StatusNotFound, "vcs integration is not available on this deployment") + return + } if !h.isVCSConfigured() { writeError(w, http.StatusServiceUnavailable, "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)") return diff --git a/server/internal/handler/vcs_webhook.go b/server/internal/handler/vcs_webhook.go index 45a6ebf3f7..ca357fc70d 100644 --- a/server/internal/handler/vcs_webhook.go +++ b/server/internal/handler/vcs_webhook.go @@ -85,6 +85,13 @@ func vcsPullRequestRowToResponse(p db.ListVCSPullRequestsByIssueRow) GitHubPullR // adapter handles the provider-specific signature scheme, event header, and // payload shape, returning normalized events to the shared mirror logic below. func (h *Handler) HandleVCSWebhook(w http.ResponseWriter, r *http.Request) { + // Where the integration is off (the managed cloud) the endpoint behaves as + // if it does not exist — a bare 404 that reveals nothing about config, the + // same response a genuinely unknown connection id gets below. + if !h.isVCSAvailable() { + writeError(w, http.StatusNotFound, "unknown connection") + return + } if !h.isVCSConfigured() { writeError(w, http.StatusServiceUnavailable, "vcs webhooks not configured") return diff --git a/server/internal/handler/vcs_webhook_test.go b/server/internal/handler/vcs_webhook_test.go index 67078e773a..074c935f65 100644 --- a/server/internal/handler/vcs_webhook_test.go +++ b/server/internal/handler/vcs_webhook_test.go @@ -27,7 +27,14 @@ func withVCSBox(t *testing.T) *secretbox.Box { } prev := testHandler.VCSSecretBox testHandler.VCSSecretBox = box - t.Cleanup(func() { testHandler.VCSSecretBox = prev }) + // The feature also requires the deployment-level switch; the box alone is + // no longer sufficient. Enable it for the "configured" path tests. + prevEnabled := testHandler.cfg.VCSIntegrationEnabled + testHandler.cfg.VCSIntegrationEnabled = true + t.Cleanup(func() { + testHandler.VCSSecretBox = prev + testHandler.cfg.VCSIntegrationEnabled = prevEnabled + }) return box } @@ -504,6 +511,31 @@ func TestVCSWebhook_UnknownConnection(t *testing.T) { } } +// TestVCSWebhook_DisabledDeploymentReturns404 verifies the deployment-level +// switch is enforced server-side: with the integration off (the managed-cloud +// posture), even a valid, correctly-signed delivery to a real connection is +// rejected with a bare 404, so the feature is never processed and reveals +// nothing about config. The availability gate short-circuits before signature +// verification. +func TestVCSWebhook_DisabledDeploymentReturns404(t *testing.T) { + ctx := context.Background() + box := withVCSBox(t) // sets the box AND enables the switch + connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") + t.Cleanup(func() { cleanupVCS(ctx, "") }) + + // Now flip the deployment switch off (withVCSBox's cleanup restores it). + testHandler.cfg.VCSIntegrationEnabled = false + + raw := []byte(`{"action":"opened","pull_request":{"number":1}}`) + w := httptest.NewRecorder() + testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ + "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw), + }, raw)) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 when integration disabled, got %d (%s)", w.Code, w.Body.String()) + } +} + func TestVCSWebhook_MalformedTolerated(t *testing.T) { ctx := context.Background() box := withVCSBox(t)