Files
multica/packages/core/config/index.ts
Bohan Jiang 73b0015475 feat(vcs): make self-hosted Git providers self-host-only (MUL-3772, MUL-5138) (#5888)
* feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772)

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 <github@multica.ai>

* fix(vcs): complete self-host integration gating (MUL-5138)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 16:39:22 +08:00

86 lines
3.2 KiB
TypeScript

import { createStore } from "zustand/vanilla";
import { useStore } from "zustand";
interface ConfigState {
cdnDomain: string;
// True when cdnDomain serves private content via time-bounded signed URLs
// (CloudFront signing enabled server-side). Renderers must not treat a raw
// storage URL on that domain as a loadable media source (MUL-3254).
cdnSigned: boolean;
allowSignup: boolean;
googleClientId: string;
daemonServerUrl: string;
daemonAppUrl: string;
// Self-host gate (#3433): when true, every "Create workspace" affordance
// 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<string, boolean>;
// The running API build version, surfaced in the Help popover so
// self-hosted operators can confirm what's deployed. Empty for dev builds
// or servers older than this feature.
serverVersion: string;
setCdnConfig: (config: { cdnDomain: string; cdnSigned?: boolean }) => void;
setAuthConfig: (config: {
allowSignup: boolean;
googleClientId?: string;
workspaceCreationDisabled?: boolean;
vcsIntegrationAvailable?: boolean;
}) => void;
setDaemonConfig: (config: {
daemonServerUrl?: string;
daemonAppUrl?: string;
}) => void;
setFeatureFlags: (flags?: Record<string, boolean>) => void;
setServerVersion: (version?: string) => void;
}
export const configStore = createStore<ConfigState>((set) => ({
cdnDomain: "",
cdnSigned: false,
allowSignup: true,
googleClientId: "",
daemonServerUrl: "",
daemonAppUrl: "",
workspaceCreationDisabled: false,
vcsIntegrationAvailable: false,
featureFlags: {},
serverVersion: "",
setCdnConfig: ({ cdnDomain, cdnSigned = false }) => set({ cdnDomain, cdnSigned }),
setAuthConfig: ({
allowSignup,
googleClientId = "",
workspaceCreationDisabled = false,
vcsIntegrationAvailable = false,
}) => set({ allowSignup, googleClientId, workspaceCreationDisabled, vcsIntegrationAvailable }),
setDaemonConfig: ({ daemonServerUrl = "", daemonAppUrl = "" }) =>
set({ daemonServerUrl, daemonAppUrl }),
setFeatureFlags: (flags = {}) => set({ featureFlags: { ...flags } }),
setServerVersion: (version = "") => set({ serverVersion: version }),
}));
export function useConfigStore(): ConfigState;
export function useConfigStore<T>(selector: (state: ConfigState) => T): T;
export function useConfigStore<T>(selector?: (state: ConfigState) => T) {
return useStore(configStore, selector as (state: ConfigState) => T);
}
export function featureFlagEnabled(
flags: Readonly<Record<string, boolean>> | undefined,
key: string,
defaultValue = false,
): boolean {
return flags?.[key] ?? defaultValue;
}
export function useFeatureEnabled(key: string, defaultValue = false): boolean {
return useConfigStore((state) =>
featureFlagEnabled(state.featureFlags, key, defaultValue),
);
}