mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 19:35:53 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6b6f5e6d3 | |||
| ed42df34c4 | |||
| c5d629ef25 |
+3
-2
@@ -183,11 +183,12 @@ AZUREAD_TENANT_ID=
|
||||
# Configure Formbricks AI at the instance level
|
||||
# Set the provider used for AI features on this instance.
|
||||
# Accepted values for AI_PROVIDER: aws, google, azure
|
||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching provider settings below.
|
||||
# AI_PROVIDER=google
|
||||
# AI_MODEL=gemini-2.5-flash
|
||||
|
||||
# Google Cloud credentials for Gemini models
|
||||
# Google Cloud settings for Gemini models
|
||||
# Credentials are optional when Application Default Credentials are available.
|
||||
# AI_GOOGLE_CLOUD_PROJECT=
|
||||
# AI_GOOGLE_CLOUD_LOCATION=
|
||||
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
|
||||
|
||||
@@ -123,6 +123,7 @@ describe("authenticateRequest", () => {
|
||||
workspaceName: "Workspace 1",
|
||||
},
|
||||
],
|
||||
feedbackDirectoryPermissions: [],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all",
|
||||
@@ -161,6 +162,7 @@ describe("authenticateRequest", () => {
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyWorkspaces: [],
|
||||
apiKeyFeedbackDirectories: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
@@ -169,6 +171,7 @@ describe("authenticateRequest", () => {
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all",
|
||||
@@ -189,6 +192,16 @@ describe("authenticateRequest", () => {
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyWorkspaces: [],
|
||||
apiKeyFeedbackDirectories: [
|
||||
{
|
||||
feedbackDirectoryId: "clxx1234567890123456789012",
|
||||
permission: "write" as const,
|
||||
feedbackDirectory: {
|
||||
id: "clxx1234567890123456789012",
|
||||
name: "Directory 1",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
@@ -196,6 +209,13 @@ describe("authenticateRequest", () => {
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [
|
||||
{
|
||||
feedbackDirectoryId: "clxx1234567890123456789012",
|
||||
feedbackDirectoryName: "Directory 1",
|
||||
permission: "write",
|
||||
},
|
||||
],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all",
|
||||
|
||||
@@ -221,6 +221,7 @@ describe("withV3ApiWrapper", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: false } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
|
||||
const handler = vi.fn(async ({ authentication }) => {
|
||||
@@ -259,6 +260,7 @@ describe("withV3ApiWrapper", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
|
||||
@@ -98,6 +98,7 @@ function createMockRequest({ method = "GET", url = "https://api.test/endpoint",
|
||||
const mockApiAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
apiKeyId: "api-key-1",
|
||||
organizationId: "org-1",
|
||||
organizationAccess: "all" as const,
|
||||
|
||||
@@ -1784,7 +1784,10 @@ checksums:
|
||||
workspace/api_keys/api_key_updated: 0e03754eb33742b4ee8d5fdad64c9b3f
|
||||
workspace/api_keys/delete_api_key_confirmation: b2f0342d4e55f0cb244fe121eeeb10a3
|
||||
workspace/api_keys/duplicate_access: 7ac7ac5ba755ce94e6fc81afa5a21997
|
||||
workspace/api_keys/duplicate_directory_access: 4230950c0bf6ebf23410b04627fb7bd3
|
||||
workspace/api_keys/feedback_directory_access: 6de029dfe5192496d06a7bbf0f52effd
|
||||
workspace/api_keys/no_api_keys_yet: 58593ed9f7e507dcd7ca7fe069add599
|
||||
workspace/api_keys/no_directory_permissions_found: 8296e96142af0f8b98ff62535f42fc5c
|
||||
workspace/api_keys/no_workspace_permissions_found: 1d719624828a9d3e433cdf6b387549f3
|
||||
workspace/api_keys/organization_access: 96a92fa907b15e0c0e47e33cac15be88
|
||||
workspace/api_keys/organization_access_description: 773dfeaf6ffbf34dd9a0a3d656a6d83c
|
||||
@@ -1792,6 +1795,7 @@ checksums:
|
||||
workspace/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
|
||||
workspace/api_keys/unable_to_copy_api_key: 148506832e31d033fa3569ce292d2120
|
||||
workspace/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
|
||||
workspace/api_keys/unknown_directory: ed07f55f5dba1f451a45f2cf6e01c9a9
|
||||
workspace/api_keys/unknown_workspace: 4b0df2d07ebc9ab084158b1b9525ae5e
|
||||
workspace/api_keys/workspace_access: b38cb73197ef5f5fa6653b88c68aa0bd
|
||||
workspace/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
|
||||
|
||||
@@ -79,6 +79,35 @@ describe("env", () => {
|
||||
expect(env.DEBUG_SHOW_RESET_LINK).toBe("1");
|
||||
});
|
||||
|
||||
test("allows Google Cloud AI configuration to rely on ADC credentials", async () => {
|
||||
setTestEnv({
|
||||
AI_PROVIDER: "google",
|
||||
AI_MODEL: "gemini-2.5-flash",
|
||||
AI_GOOGLE_CLOUD_PROJECT: "test-project",
|
||||
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
|
||||
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: undefined,
|
||||
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: undefined,
|
||||
});
|
||||
|
||||
const { env } = await import("./env");
|
||||
|
||||
expect(env.AI_PROVIDER).toBe("google");
|
||||
expect(env.AI_GOOGLE_CLOUD_PROJECT).toBe("test-project");
|
||||
expect(env.AI_GOOGLE_CLOUD_LOCATION).toBe("us-central1");
|
||||
});
|
||||
|
||||
test("fails to load when Google Cloud credentials JSON is invalid", async () => {
|
||||
setTestEnv({
|
||||
AI_PROVIDER: "google",
|
||||
AI_MODEL: "gemini-2.5-flash",
|
||||
AI_GOOGLE_CLOUD_PROJECT: "test-project",
|
||||
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
|
||||
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: "{not-json}",
|
||||
});
|
||||
|
||||
await expect(import("./env")).rejects.toThrow("AI_GOOGLE_CLOUD_CREDENTIALS_JSON");
|
||||
});
|
||||
|
||||
test("uses the configured Cube environment variables", async () => {
|
||||
setTestEnv();
|
||||
const { env } = await import("./env");
|
||||
|
||||
@@ -68,14 +68,6 @@ const validateGoogleAIConfiguration = (values: TAIConfigurationEnv, ctx: z.Refin
|
||||
);
|
||||
}
|
||||
|
||||
if (!values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON && !values.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS) {
|
||||
addEnvIssue(
|
||||
ctx,
|
||||
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON",
|
||||
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON or AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS is required when AI_PROVIDER=google"
|
||||
);
|
||||
}
|
||||
|
||||
if (values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON) {
|
||||
try {
|
||||
const parsedCredentials = JSON.parse(values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON) as unknown;
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "API-Schlüssel aktualisiert",
|
||||
"delete_api_key_confirmation": "Alle Anwendungen, die diesen Schlüssel verwenden, können nicht mehr auf deine Formbricks-Daten zugreifen.",
|
||||
"duplicate_access": "Doppelter Workspace-Zugriff ist nicht erlaubt",
|
||||
"duplicate_directory_access": "Doppelter Zugriff auf Feedback-Verzeichnis nicht erlaubt",
|
||||
"feedback_directory_access": "Feedback-Verzeichnis-Zugriff",
|
||||
"no_api_keys_yet": "Du hast noch keine API-Schlüssel",
|
||||
"no_directory_permissions_found": "Keine Berechtigungen für Feedback-Verzeichnisse gefunden",
|
||||
"no_workspace_permissions_found": "Keine Workspace-Berechtigungen gefunden",
|
||||
"organization_access": "Organisations-Zugriff",
|
||||
"organization_access_description": "Wähle Lese- oder Schreibrechte für organisationsweite Ressourcen aus.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Geheimnis",
|
||||
"unable_to_copy_api_key": "API-Schlüssel konnte nicht kopiert werden",
|
||||
"unable_to_delete_api_key": "API-Schlüssel konnte nicht gelöscht werden",
|
||||
"unknown_directory": "Unbekanntes Verzeichnis",
|
||||
"unknown_workspace": "Unbekannter Arbeitsbereich",
|
||||
"workspace_access": "Workspace-Zugriff"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "API Key updated",
|
||||
"delete_api_key_confirmation": "Any applications using this key will no longer be able to access your Formbricks data.",
|
||||
"duplicate_access": "Duplicate workspace access not allowed",
|
||||
"duplicate_directory_access": "Duplicate feedback directory access not allowed",
|
||||
"feedback_directory_access": "Feedback Directory Access",
|
||||
"no_api_keys_yet": "You do not have any API keys yet",
|
||||
"no_directory_permissions_found": "No feedback directory permissions found",
|
||||
"no_workspace_permissions_found": "No Workspace permissions found",
|
||||
"organization_access": "Organization Access",
|
||||
"organization_access_description": "Select read or write privileges for organization-wide resources.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Secret",
|
||||
"unable_to_copy_api_key": "Unable to copy API key",
|
||||
"unable_to_delete_api_key": "Unable to delete API Key",
|
||||
"unknown_directory": "Unknown directory",
|
||||
"unknown_workspace": "Unknown workspace",
|
||||
"workspace_access": "Workspace Access"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "Clave API actualizada",
|
||||
"delete_api_key_confirmation": "Cualquier aplicación que use esta clave ya no podrá acceder a tus datos de Formbricks.",
|
||||
"duplicate_access": "No se permite el acceso duplicado al espacio de trabajo",
|
||||
"duplicate_directory_access": "No se permite el acceso duplicado al directorio de feedback",
|
||||
"feedback_directory_access": "Acceso al directorio de feedback",
|
||||
"no_api_keys_yet": "Aún no tienes ninguna clave API",
|
||||
"no_directory_permissions_found": "No se encontraron permisos para el directorio de feedback",
|
||||
"no_workspace_permissions_found": "No se encontraron permisos del espacio de trabajo",
|
||||
"organization_access": "Acceso a la organización",
|
||||
"organization_access_description": "Selecciona privilegios de lectura o escritura para recursos de toda la organización.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Secreto",
|
||||
"unable_to_copy_api_key": "No se puede copiar la clave de API",
|
||||
"unable_to_delete_api_key": "No se puede eliminar la clave API",
|
||||
"unknown_directory": "Directorio desconocido",
|
||||
"unknown_workspace": "Espacio de trabajo desconocido",
|
||||
"workspace_access": "Acceso al espacio de trabajo"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "Clé API mise à jour",
|
||||
"delete_api_key_confirmation": "Toute application utilisant cette clé ne pourra plus accéder à vos données Formbricks.",
|
||||
"duplicate_access": "Accès en double à l'espace de travail non autorisé",
|
||||
"duplicate_directory_access": "L'accès en double au répertoire de retours n'est pas autorisé",
|
||||
"feedback_directory_access": "Accès au répertoire de retours",
|
||||
"no_api_keys_yet": "Vous n'avez pas encore de clés API",
|
||||
"no_directory_permissions_found": "Aucune autorisation de répertoire de retours trouvée",
|
||||
"no_workspace_permissions_found": "Aucune autorisation d'espace de travail trouvée",
|
||||
"organization_access": "Accès à l'organisation",
|
||||
"organization_access_description": "Sélectionnez les privilèges de lecture ou d'écriture pour les ressources à l'échelle de l'organisation.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Secret",
|
||||
"unable_to_copy_api_key": "Impossible de copier la clé API",
|
||||
"unable_to_delete_api_key": "Impossible de supprimer la clé API",
|
||||
"unknown_directory": "Répertoire inconnu",
|
||||
"unknown_workspace": "Espace de travail inconnu",
|
||||
"workspace_access": "Accès à l'espace de travail"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "API-kulcs frissítve",
|
||||
"delete_api_key_confirmation": "Az ezt a kulcsot használó bármely alkalmazás többé nem fog tudni hozzáférni a Formbricks adataihoz.",
|
||||
"duplicate_access": "A kettőzött munkaterület-hozzáférés nem engedélyezett",
|
||||
"duplicate_directory_access": "Duplikált visszajelzési könyvtár hozzáférés nem engedélyezett",
|
||||
"feedback_directory_access": "Visszajelzési könyvtár hozzáférés",
|
||||
"no_api_keys_yet": "Még nincs semmilyen API-kulcsa",
|
||||
"no_directory_permissions_found": "Nem találhatók visszajelzési könyvtár jogosultságok",
|
||||
"no_workspace_permissions_found": "Nem találhatók munkaterület-jogosultságok",
|
||||
"organization_access": "Szervezeti hozzáférés",
|
||||
"organization_access_description": "Olvasási vagy írási jogosultságok kiválasztása a teljes szervezetre vonatkozó erőforrásokhoz.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Titok",
|
||||
"unable_to_copy_api_key": "Az API kulcs másolása nem lehetséges",
|
||||
"unable_to_delete_api_key": "Nem lehet törölni az API-kulcsot",
|
||||
"unknown_directory": "Ismeretlen könyvtár",
|
||||
"unknown_workspace": "Ismeretlen munkaterület",
|
||||
"workspace_access": "Munkaterület-hozzáférés"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "APIキーを更新しました",
|
||||
"delete_api_key_confirmation": "このキーを使用しているアプリケーションは、Formbricksデータにアクセスできなくなります。",
|
||||
"duplicate_access": "ワークスペースへの重複アクセスは許可されていません",
|
||||
"duplicate_directory_access": "フィードバックディレクトリへの重複アクセスは許可されていません",
|
||||
"feedback_directory_access": "フィードバックディレクトリアクセス",
|
||||
"no_api_keys_yet": "まだAPIキーがありません",
|
||||
"no_directory_permissions_found": "フィードバックディレクトリの権限が見つかりません",
|
||||
"no_workspace_permissions_found": "ワークスペースの権限が見つかりません",
|
||||
"organization_access": "組織アクセス",
|
||||
"organization_access_description": "組織全体のリソースに対する読み取りまたは書き込み権限を選択してください。",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "シークレット",
|
||||
"unable_to_copy_api_key": "APIキーをコピーできません",
|
||||
"unable_to_delete_api_key": "APIキーを削除できません",
|
||||
"unknown_directory": "不明なディレクトリ",
|
||||
"unknown_workspace": "不明なワークスペース",
|
||||
"workspace_access": "ワークスペースアクセス"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "API-sleutel bijgewerkt",
|
||||
"delete_api_key_confirmation": "Alle applicaties die deze sleutel gebruiken, hebben geen toegang meer tot uw Formbricks-gegevens.",
|
||||
"duplicate_access": "Dubbele workspace-toegang niet toegestaan",
|
||||
"duplicate_directory_access": "Dubbele feedbackmaptoegang niet toegestaan",
|
||||
"feedback_directory_access": "Feedbackmaptoegang",
|
||||
"no_api_keys_yet": "U heeft nog geen API-sleutels",
|
||||
"no_directory_permissions_found": "Geen feedbackmaprechten gevonden",
|
||||
"no_workspace_permissions_found": "Geen Workspace-rechten gevonden",
|
||||
"organization_access": "Organisatietoegang",
|
||||
"organization_access_description": "Selecteer lees- of schrijfrechten voor organisatiebrede bronnen.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Geheim",
|
||||
"unable_to_copy_api_key": "Kan API-sleutel niet kopiëren",
|
||||
"unable_to_delete_api_key": "Kan API-sleutel niet verwijderen",
|
||||
"unknown_directory": "Onbekende map",
|
||||
"unknown_workspace": "Onbekende workspace",
|
||||
"workspace_access": "Workspace-toegang"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "Chave de API atualizada",
|
||||
"delete_api_key_confirmation": "Quaisquer aplicativos que usem esta chave não poderão mais acessar seus dados do Formbricks.",
|
||||
"duplicate_access": "Acesso duplicado ao workspace não permitido",
|
||||
"duplicate_directory_access": "Acesso duplicado ao diretório de feedback não permitido",
|
||||
"feedback_directory_access": "Acesso ao Diretório de Feedback",
|
||||
"no_api_keys_yet": "Você ainda não tem nenhuma chave de API",
|
||||
"no_directory_permissions_found": "Nenhuma permissão de diretório de feedback encontrada",
|
||||
"no_workspace_permissions_found": "Nenhuma permissão de Workspace encontrada",
|
||||
"organization_access": "Acesso à organização",
|
||||
"organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Segredo",
|
||||
"unable_to_copy_api_key": "Não foi possível copiar a chave de API",
|
||||
"unable_to_delete_api_key": "Não foi possível excluir a chave de API",
|
||||
"unknown_directory": "Diretório desconhecido",
|
||||
"unknown_workspace": "Workspace desconhecido",
|
||||
"workspace_access": "Acesso ao workspace"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "Chave API atualizada",
|
||||
"delete_api_key_confirmation": "Quaisquer aplicações que utilizem esta chave deixarão de poder aceder aos seus dados Formbricks.",
|
||||
"duplicate_access": "Acesso duplicado ao workspace não permitido",
|
||||
"duplicate_directory_access": "Não é permitido acesso duplicado ao diretório de feedback",
|
||||
"feedback_directory_access": "Acesso ao Diretório de Feedback",
|
||||
"no_api_keys_yet": "Ainda não tem nenhuma chave de API",
|
||||
"no_directory_permissions_found": "Nenhuma permissão de diretório de feedback encontrada",
|
||||
"no_workspace_permissions_found": "Não foram encontradas permissões de Espaço de Trabalho",
|
||||
"organization_access": "Acesso à organização",
|
||||
"organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Segredo",
|
||||
"unable_to_copy_api_key": "Não foi possível copiar a chave API",
|
||||
"unable_to_delete_api_key": "Não foi possível eliminar a chave de API",
|
||||
"unknown_directory": "Diretório desconhecido",
|
||||
"unknown_workspace": "Área de trabalho desconhecida",
|
||||
"workspace_access": "Acesso ao workspace"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "Cheie API actualizată",
|
||||
"delete_api_key_confirmation": "Orice aplicație care folosește această cheie nu va mai putea accesa datele dumneavoastră Formbricks.",
|
||||
"duplicate_access": "Acces duplicat la spațiul de lucru nu este permis",
|
||||
"duplicate_directory_access": "Accesul duplicat la directorul de feedback nu este permis",
|
||||
"feedback_directory_access": "Acces la Directorul de Feedback",
|
||||
"no_api_keys_yet": "Nu ai încă nicio cheie API",
|
||||
"no_directory_permissions_found": "Nu au fost găsite permisiuni pentru directoare de feedback",
|
||||
"no_workspace_permissions_found": "Nu s-au găsit permisiuni pentru Workspace",
|
||||
"organization_access": "Acces organizație",
|
||||
"organization_access_description": "Selectează privilegii de citire sau scriere pentru resursele la nivel de organizație.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Secret",
|
||||
"unable_to_copy_api_key": "Nu se poate copia cheia API",
|
||||
"unable_to_delete_api_key": "Nu se poate șterge cheia API",
|
||||
"unknown_directory": "Director necunoscut",
|
||||
"unknown_workspace": "Spațiu de lucru necunoscut",
|
||||
"workspace_access": "Acces spațiu de lucru"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "API-ключ обновлён",
|
||||
"delete_api_key_confirmation": "Любые приложения, использующие этот ключ, больше не смогут получить доступ к вашим данным Formbricks.",
|
||||
"duplicate_access": "Дублированный доступ к рабочему пространству не разрешён",
|
||||
"duplicate_directory_access": "Дублирование доступа к директории обратной связи запрещено",
|
||||
"feedback_directory_access": "Доступ к директории обратной связи",
|
||||
"no_api_keys_yet": "У вас ещё нет API-ключей",
|
||||
"no_directory_permissions_found": "Разрешения для директорий обратной связи не найдены",
|
||||
"no_workspace_permissions_found": "Разрешения для рабочего пространства не найдены",
|
||||
"organization_access": "Доступ к организации",
|
||||
"organization_access_description": "Выберите права на чтение или запись для ресурсов всей организации.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Секрет",
|
||||
"unable_to_copy_api_key": "Не удалось скопировать API-ключ",
|
||||
"unable_to_delete_api_key": "Не удалось удалить API-ключ",
|
||||
"unknown_directory": "Неизвестный каталог",
|
||||
"unknown_workspace": "Неизвестное рабочее пространство",
|
||||
"workspace_access": "Доступ к рабочему пространству"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "API-nyckel uppdaterad",
|
||||
"delete_api_key_confirmation": "Alla applikationer som använder denna nyckel kommer inte längre att kunna komma åt din Formbricks-data.",
|
||||
"duplicate_access": "Duplicerad arbetsyteåtkomst är inte tillåten",
|
||||
"duplicate_directory_access": "Duplicerad åtkomst till feedback-katalog är inte tillåten",
|
||||
"feedback_directory_access": "Åtkomst till feedback-katalog",
|
||||
"no_api_keys_yet": "Du har inga API-nycklar ännu",
|
||||
"no_directory_permissions_found": "Inga behörigheter för feedback-katalog hittades",
|
||||
"no_workspace_permissions_found": "Inga behörigheter för arbetsytan hittades",
|
||||
"organization_access": "Organisationsåtkomst",
|
||||
"organization_access_description": "Välj läs- eller skrivbehörighet för resurser på organisationsnivå.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Hemlig",
|
||||
"unable_to_copy_api_key": "Kunde inte kopiera API-nyckel",
|
||||
"unable_to_delete_api_key": "Det gick inte att radera API-nyckeln",
|
||||
"unknown_directory": "Okänd katalog",
|
||||
"unknown_workspace": "Okänd arbetsyta",
|
||||
"workspace_access": "Arbetsyteåtkomst"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "API anahtarı güncellendi",
|
||||
"delete_api_key_confirmation": "Bu anahtarı kullanan tüm uygulamalar artık Formbricks verilerine erişemeyecek.",
|
||||
"duplicate_access": "Yinelenen çalışma alanı erişimine izin verilmiyor",
|
||||
"duplicate_directory_access": "Yinelenen geri bildirim dizini erişimine izin verilmiyor",
|
||||
"feedback_directory_access": "Geri Bildirim Dizini Erişimi",
|
||||
"no_api_keys_yet": "Henüz hiç API anahtarınız yok",
|
||||
"no_directory_permissions_found": "Geri bildirim dizini izni bulunamadı",
|
||||
"no_workspace_permissions_found": "Çalışma Alanı izni bulunamadı",
|
||||
"organization_access": "Organizasyon Erişimi",
|
||||
"organization_access_description": "Organizasyon genelindeki kaynaklar için okuma veya yazma yetkilerini seçin.",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "Gizli Anahtar",
|
||||
"unable_to_copy_api_key": "API anahtarı kopyalanamıyor",
|
||||
"unable_to_delete_api_key": "API anahtarı silinemiyor",
|
||||
"unknown_directory": "Bilinmeyen dizin",
|
||||
"unknown_workspace": "Bilinmeyen çalışma alanı",
|
||||
"workspace_access": "Çalışma Alanı Erişimi"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "API 密钥已更新",
|
||||
"delete_api_key_confirmation": "使用此密钥的任何应用将无法再访问您的 Formbricks 数据。",
|
||||
"duplicate_access": "不允许重复的工作区访问权限",
|
||||
"duplicate_directory_access": "不允许重复的反馈目录访问权限",
|
||||
"feedback_directory_access": "反馈目录访问权限",
|
||||
"no_api_keys_yet": "您还没有任何 API 密钥",
|
||||
"no_directory_permissions_found": "未找到反馈目录权限",
|
||||
"no_workspace_permissions_found": "未找到工作区权限",
|
||||
"organization_access": "组织访问权限",
|
||||
"organization_access_description": "为组织范围的资源选择读取或写入权限。",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "密钥",
|
||||
"unable_to_copy_api_key": "无法复制 API 密钥",
|
||||
"unable_to_delete_api_key": "无法删除 API 密钥",
|
||||
"unknown_directory": "未知目录",
|
||||
"unknown_workspace": "未知工作区",
|
||||
"workspace_access": "工作区访问权限"
|
||||
},
|
||||
|
||||
@@ -1854,7 +1854,10 @@
|
||||
"api_key_updated": "API 金鑰已更新",
|
||||
"delete_api_key_confirmation": "使用此金鑰的任何應用程式將無法再存取您的 Formbricks 資料。",
|
||||
"duplicate_access": "不允許重複工作區存取",
|
||||
"duplicate_directory_access": "不允許重複的意見回饋目錄存取權限",
|
||||
"feedback_directory_access": "意見回饋目錄存取權限",
|
||||
"no_api_keys_yet": "您目前尚未有任何 API 金鑰",
|
||||
"no_directory_permissions_found": "找不到意見回饋目錄權限",
|
||||
"no_workspace_permissions_found": "找不到工作區權限",
|
||||
"organization_access": "組織存取",
|
||||
"organization_access_description": "請選擇組織層級資源的讀取或寫入權限。",
|
||||
@@ -1862,6 +1865,7 @@
|
||||
"secret": "密鑰",
|
||||
"unable_to_copy_api_key": "無法複製 API 金鑰",
|
||||
"unable_to_delete_api_key": "無法刪除 API 金鑰",
|
||||
"unknown_directory": "未知目錄",
|
||||
"unknown_workspace": "未知工作區",
|
||||
"workspace_access": "工作區存取"
|
||||
},
|
||||
|
||||
@@ -54,6 +54,11 @@ export const authenticateApiKeyFromHeaders = async (
|
||||
workspaceId: workspacePermission.workspaceId,
|
||||
workspaceName: workspacePermission.workspace.name,
|
||||
})),
|
||||
feedbackDirectoryPermissions: (apiKeyData.apiKeyFeedbackDirectories ?? []).map((directoryPermission) => ({
|
||||
permission: directoryPermission.permission,
|
||||
feedbackDirectoryId: directoryPermission.feedbackDirectoryId,
|
||||
feedbackDirectoryName: directoryPermission.feedbackDirectory.name,
|
||||
})),
|
||||
apiKeyId: apiKeyData.id,
|
||||
organizationId: apiKeyData.organizationId,
|
||||
organizationAccess: apiKeyData.organizationAccess,
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("authenticateRequest", () => {
|
||||
workspaceName: "Workspace 2",
|
||||
},
|
||||
],
|
||||
feedbackDirectoryPermissions: [],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
@@ -119,6 +120,7 @@ describe("authenticateRequest", () => {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
apiKeyId: "org-api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
@@ -199,6 +201,17 @@ describe("authenticateRequest", () => {
|
||||
},
|
||||
},
|
||||
apiKeyWorkspaces: [],
|
||||
apiKeyFeedbackDirectories: [
|
||||
{
|
||||
feedbackDirectoryId: "clxx1234567890123456789012",
|
||||
apiKeyId: "bearer-api-key-id",
|
||||
permission: "read",
|
||||
feedbackDirectory: {
|
||||
id: "clxx1234567890123456789012",
|
||||
name: "Directory 1",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as TApiKeyWithEnvironmentAndWorkspace);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
@@ -208,6 +221,13 @@ describe("authenticateRequest", () => {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [
|
||||
{
|
||||
feedbackDirectoryId: "clxx1234567890123456789012",
|
||||
feedbackDirectoryName: "Directory 1",
|
||||
permission: "read",
|
||||
},
|
||||
],
|
||||
apiKeyId: "bearer-api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
|
||||
@@ -129,6 +129,13 @@ describe("authorizeEnvoyRequest", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [
|
||||
{
|
||||
feedbackDirectoryId,
|
||||
feedbackDirectoryName: "Directory 1",
|
||||
permission: "write",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
@@ -155,6 +162,7 @@ describe("authorizeEnvoyRequest", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
@@ -185,6 +193,7 @@ describe("authorizeEnvoyRequest", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
mockGetFeedbackRecordTenant.mockResolvedValue({
|
||||
data: null,
|
||||
@@ -214,6 +223,7 @@ describe("authorizeEnvoyRequest", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
mockGetFeedbackRecordTenant.mockResolvedValue({
|
||||
data: null,
|
||||
@@ -316,14 +326,15 @@ describe("authorizeEnvoyRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 403 when an API key has no matching workspace or org-level access", async () => {
|
||||
test("returns 403 when an API key lacks directory permission", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: false, write: false } },
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
@@ -338,124 +349,6 @@ describe("authorizeEnvoyRequest", () => {
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
test("allows API key with workspace write permission on a linked workspace", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: false, write: false } },
|
||||
workspacePermissions: [
|
||||
{
|
||||
workspaceId: "workspace_1",
|
||||
workspaceName: "Workspace 1",
|
||||
permission: "write",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": "fbk_test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns 403 when API key has read-only workspace permission for a write op", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: false, write: false } },
|
||||
workspacePermissions: [
|
||||
{
|
||||
workspaceId: "workspace_1",
|
||||
workspaceName: "Workspace 1",
|
||||
permission: "read",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
createRequest("http://localhost/api/envoy-auth/api/v3/feedbackRecords", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": "fbk_test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
test("returns 403 when FRD has no workspace links and API key has no org-level access", async () => {
|
||||
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
|
||||
organizationId: "org_1",
|
||||
workspaceIds: [],
|
||||
isArchived: false,
|
||||
});
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: false, write: false } },
|
||||
workspacePermissions: [
|
||||
{
|
||||
workspaceId: "workspace_1",
|
||||
workspaceName: "Workspace 1",
|
||||
permission: "manage",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, {
|
||||
headers: {
|
||||
"x-api-key": "fbk_test",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
test("allows API key with org-level read access for a read op even without workspace match", async () => {
|
||||
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
|
||||
organizationId: "org_1",
|
||||
workspaceIds: [],
|
||||
isArchived: false,
|
||||
});
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: false } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, {
|
||||
headers: {
|
||||
"x-api-key": "fbk_test",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns 403 when unify feedback entitlement is disabled", async () => {
|
||||
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(false);
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
@@ -465,6 +358,13 @@ describe("authorizeEnvoyRequest", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [
|
||||
{
|
||||
feedbackDirectoryId,
|
||||
feedbackDirectoryName: "Directory 1",
|
||||
permission: "write",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
@@ -507,6 +407,7 @@ describe("authorizeEnvoyRequest", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
@@ -531,6 +432,7 @@ describe("authorizeEnvoyRequest", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
@@ -553,6 +455,7 @@ describe("authorizeEnvoyRequest", () => {
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeEnvoyRequest(
|
||||
|
||||
@@ -165,29 +165,23 @@ const getFeedbackRecordsGatewayJwtFromHeaders = (headers: Headers): string | nul
|
||||
return getBearerTokenFromHeaders(headers);
|
||||
};
|
||||
|
||||
const hasApiKeyImplicitFeedbackDirectoryAccess = (
|
||||
const hasFeedbackDirectoryPermission = (
|
||||
authentication: TAuthenticationApiKey,
|
||||
workspaceIds: string[],
|
||||
feedbackDirectoryId: string,
|
||||
requiredPermission: TFeedbackRecordsGatewayPermission
|
||||
): boolean => {
|
||||
const orgAccessControl = authentication.organizationAccess?.accessControl;
|
||||
if (orgAccessControl?.write) {
|
||||
return true;
|
||||
}
|
||||
if (orgAccessControl?.read && requiredPermission === "read") {
|
||||
return true;
|
||||
}
|
||||
const feedbackDirectoryPermission = authentication.feedbackDirectoryPermissions.find(
|
||||
(permission) => permission.feedbackDirectoryId === feedbackDirectoryId
|
||||
);
|
||||
|
||||
const matchingWeights = authentication.workspacePermissions
|
||||
.filter((permission) => workspaceIds.includes(permission.workspaceId))
|
||||
.map((permission) => apiKeyPermissionWeight[permission.permission]);
|
||||
|
||||
if (matchingWeights.length === 0) {
|
||||
if (!feedbackDirectoryPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxWeight = Math.max(...matchingWeights);
|
||||
return maxWeight >= gatewayPermissionToApiKeyPermissionWeight[requiredPermission];
|
||||
return (
|
||||
apiKeyPermissionWeight[feedbackDirectoryPermission.permission] >=
|
||||
gatewayPermissionToApiKeyPermissionWeight[requiredPermission]
|
||||
);
|
||||
};
|
||||
|
||||
const resolveTenantId = async (
|
||||
@@ -270,11 +264,7 @@ const authorizeGatewayRequest = async (
|
||||
}
|
||||
|
||||
if (principal.type === "apiKey") {
|
||||
return hasApiKeyImplicitFeedbackDirectoryAccess(
|
||||
principal.authentication,
|
||||
feedbackDirectory.workspaceIds,
|
||||
requiredPermission
|
||||
)
|
||||
return hasFeedbackDirectoryPermission(principal.authentication, feedbackDirectoryId, requiredPermission)
|
||||
? { allowed: true }
|
||||
: { allowed: false };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { TOrganizationWorkspace } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import {
|
||||
TOrganizationFeedbackDirectory,
|
||||
TOrganizationWorkspace,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -37,9 +40,14 @@ interface AddApiKeyModalProps {
|
||||
permission: ApiKeyPermission;
|
||||
workspaceId: string;
|
||||
}>;
|
||||
feedbackDirectoryPermissions: Array<{
|
||||
permission: ApiKeyPermission;
|
||||
feedbackDirectoryId: string;
|
||||
}>;
|
||||
organizationAccess: TOrganizationAccess;
|
||||
}) => Promise<void>;
|
||||
workspaces: TOrganizationWorkspace[];
|
||||
feedbackDirectories: TOrganizationFeedbackDirectory[];
|
||||
isCreatingAPIKey: boolean;
|
||||
}
|
||||
|
||||
@@ -48,12 +56,23 @@ interface WorkspaceOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FeedbackDirectoryOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PermissionRecord {
|
||||
workspaceId: string;
|
||||
permission: ApiKeyPermission;
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
interface DirectoryPermissionRecord {
|
||||
feedbackDirectoryId: string;
|
||||
permission: ApiKeyPermission;
|
||||
feedbackDirectoryName: string;
|
||||
}
|
||||
|
||||
const permissionOptions = [ApiKeyPermission.read, ApiKeyPermission.write, ApiKeyPermission.manage];
|
||||
|
||||
export const AddApiKeyModal = ({
|
||||
@@ -61,6 +80,7 @@ export const AddApiKeyModal = ({
|
||||
setOpen,
|
||||
onSubmit,
|
||||
workspaces,
|
||||
feedbackDirectories,
|
||||
isCreatingAPIKey,
|
||||
}: AddApiKeyModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -89,14 +109,34 @@ export const AddApiKeyModal = ({
|
||||
return {};
|
||||
};
|
||||
|
||||
const getInitialDirectoryPermission = (): DirectoryPermissionRecord | null => {
|
||||
if (feedbackDirectories.length > 0) {
|
||||
return {
|
||||
feedbackDirectoryId: feedbackDirectories[0].id,
|
||||
permission: ApiKeyPermission.read,
|
||||
feedbackDirectoryName: feedbackDirectories[0].name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Initialize with one permission by default
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Record<string, PermissionRecord>>({});
|
||||
const [selectedDirectoryPermissions, setSelectedDirectoryPermissions] = useState<
|
||||
Record<string, DirectoryPermissionRecord>
|
||||
>({});
|
||||
const [nextDirectoryPermissionId, setNextDirectoryPermissionId] = useState(0);
|
||||
|
||||
const workspaceOptions: WorkspaceOption[] = workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
}));
|
||||
|
||||
const directoryOptions: FeedbackDirectoryOption[] = feedbackDirectories.map((directory) => ({
|
||||
id: directory.id,
|
||||
name: directory.name,
|
||||
}));
|
||||
|
||||
const removePermission = (index: number) => {
|
||||
const updatedPermissions = { ...selectedPermissions };
|
||||
delete updatedPermissions[`permission-${index}`];
|
||||
@@ -139,12 +179,59 @@ export const AddApiKeyModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const removeDirectoryPermission = (key: string) => {
|
||||
setSelectedDirectoryPermissions((prev) =>
|
||||
Object.fromEntries(Object.entries(prev).filter(([k]) => k !== key))
|
||||
);
|
||||
};
|
||||
|
||||
const addDirectoryPermission = () => {
|
||||
const initial = getInitialDirectoryPermission();
|
||||
if (initial) {
|
||||
setSelectedDirectoryPermissions({
|
||||
...selectedDirectoryPermissions,
|
||||
[`directory-permission-${nextDirectoryPermissionId}`]: initial,
|
||||
});
|
||||
setNextDirectoryPermissionId((id) => id + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDirectoryPermissionLevel = (key: string, permission: ApiKeyPermission) => {
|
||||
setSelectedDirectoryPermissions({
|
||||
...selectedDirectoryPermissions,
|
||||
[key]: {
|
||||
...selectedDirectoryPermissions[key],
|
||||
permission,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateDirectorySelection = (key: string, directoryId: string) => {
|
||||
const directory = feedbackDirectories.find((d) => d.id === directoryId);
|
||||
if (directory) {
|
||||
setSelectedDirectoryPermissions({
|
||||
...selectedDirectoryPermissions,
|
||||
[key]: {
|
||||
...selectedDirectoryPermissions[key],
|
||||
feedbackDirectoryId: directoryId,
|
||||
feedbackDirectoryName: directory.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkForDuplicatePermissions = () => {
|
||||
const permissions = Object.values(selectedPermissions);
|
||||
const uniquePermissions = new Set(permissions.map((p) => p.workspaceId));
|
||||
return uniquePermissions.size !== permissions.length;
|
||||
};
|
||||
|
||||
const checkForDuplicateDirectoryPermissions = () => {
|
||||
const permissions = Object.values(selectedDirectoryPermissions);
|
||||
const unique = new Set(permissions.map((p) => p.feedbackDirectoryId));
|
||||
return unique.size !== permissions.length;
|
||||
};
|
||||
|
||||
const submitAPIKey = async () => {
|
||||
const data = getValues();
|
||||
|
||||
@@ -153,20 +240,33 @@ export const AddApiKeyModal = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkForDuplicateDirectoryPermissions()) {
|
||||
toast.error(t("workspace.api_keys.duplicate_directory_access"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert permissions to the format expected by the API
|
||||
const workspacePermissions = Object.values(selectedPermissions).map((permission) => ({
|
||||
permission: permission.permission,
|
||||
workspaceId: permission.workspaceId,
|
||||
}));
|
||||
|
||||
const feedbackDirectoryPermissions = Object.values(selectedDirectoryPermissions).map((p) => ({
|
||||
permission: p.permission,
|
||||
feedbackDirectoryId: p.feedbackDirectoryId,
|
||||
}));
|
||||
|
||||
await onSubmit({
|
||||
label: data.label,
|
||||
workspacePermissions,
|
||||
feedbackDirectoryPermissions,
|
||||
organizationAccess: selectedOrganizationAccess,
|
||||
});
|
||||
|
||||
reset();
|
||||
setSelectedPermissions({});
|
||||
setSelectedDirectoryPermissions({});
|
||||
setNextDirectoryPermissionId(0);
|
||||
setSelectedOrganizationAccess(defaultOrganizationAccess);
|
||||
};
|
||||
|
||||
@@ -178,13 +278,14 @@ export const AddApiKeyModal = ({
|
||||
|
||||
// Check if at least one workspace permission is set or one organization access toggle is ON
|
||||
const hasWorkspaceAccess = Object.keys(selectedPermissions).length > 0;
|
||||
const hasDirectoryAccess = Object.keys(selectedDirectoryPermissions).length > 0;
|
||||
|
||||
const hasOrganizationAccess = Object.values(selectedOrganizationAccess).some((accessGroup) =>
|
||||
Object.values(accessGroup).some((value) => value === true)
|
||||
);
|
||||
|
||||
// Disable submit if no access rights are granted
|
||||
return !(hasWorkspaceAccess || hasOrganizationAccess);
|
||||
return !(hasWorkspaceAccess || hasDirectoryAccess || hasOrganizationAccess);
|
||||
};
|
||||
|
||||
const setSelectedOrganizationAccessValue = (key: string, accessType: string, value: boolean) => {
|
||||
@@ -302,6 +403,95 @@ export const AddApiKeyModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("workspace.api_keys.feedback_directory_access")}</Label>
|
||||
<div className="space-y-2">
|
||||
{Object.keys(selectedDirectoryPermissions).map((key) => {
|
||||
const permission = selectedDirectoryPermissions[key];
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
{/* Directory dropdown */}
|
||||
<div className="w-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
|
||||
<span className="flex w-4/5 flex-1">
|
||||
<span className="w-full truncate text-left">
|
||||
{permission.feedbackDirectoryName}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex h-full items-center border-l pl-3">
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto">
|
||||
{directoryOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
updateDirectorySelection(key, option.id);
|
||||
}}>
|
||||
{option.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Permission level dropdown */}
|
||||
<div className="w-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
|
||||
<span className="flex w-4/5 flex-1">
|
||||
<span className="w-full truncate text-left capitalize">
|
||||
{permission.permission}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex h-full items-center border-l pl-3">
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[8rem] capitalize">
|
||||
{permissionOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option}
|
||||
onClick={() => {
|
||||
updateDirectoryPermissionLevel(key, option);
|
||||
}}>
|
||||
{option}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button type="button" className="p-2" onClick={() => removeDirectoryPermission(key)}>
|
||||
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
id="add_directory_permission__button"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addDirectoryPermission}
|
||||
disabled={feedbackDirectories.length === 0}
|
||||
data-testid="add_directory_permission__button__test">
|
||||
<span className="mr-2">+</span> {t("workspace.settings.api_keys.add_permission")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label>{t("workspace.api_keys.organization_access")}</Label>
|
||||
{Object.keys(selectedOrganizationAccess).map((key) => (
|
||||
@@ -341,6 +531,8 @@ export const AddApiKeyModal = ({
|
||||
setOpen(false);
|
||||
reset();
|
||||
setSelectedPermissions({});
|
||||
setSelectedDirectoryPermissions({});
|
||||
setNextDirectoryPermissionId(0);
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getApiKeysWithEnvironmentPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { TOrganizationWorkspace } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import {
|
||||
TOrganizationFeedbackDirectory,
|
||||
TOrganizationWorkspace,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { EditAPIKeys } from "./edit-api-keys";
|
||||
|
||||
interface ApiKeyListProps {
|
||||
@@ -8,9 +11,16 @@ interface ApiKeyListProps {
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
workspaces: TOrganizationWorkspace[];
|
||||
feedbackDirectories: TOrganizationFeedbackDirectory[];
|
||||
}
|
||||
|
||||
export const ApiKeyList = async ({ organizationId, locale, isReadOnly, workspaces }: ApiKeyListProps) => {
|
||||
export const ApiKeyList = async ({
|
||||
organizationId,
|
||||
locale,
|
||||
isReadOnly,
|
||||
workspaces,
|
||||
feedbackDirectories,
|
||||
}: ApiKeyListProps) => {
|
||||
const apiKeys = await getApiKeysWithEnvironmentPermissions(organizationId);
|
||||
|
||||
return (
|
||||
@@ -20,6 +30,7 @@ export const ApiKeyList = async ({ organizationId, locale, isReadOnly, workspace
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
workspaces={workspaces}
|
||||
feedbackDirectories={feedbackDirectories}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ViewPermissionModal } from "@/modules/organization/settings/api-keys/co
|
||||
import {
|
||||
TApiKeyUpdateInput,
|
||||
TApiKeyWithEnvironmentPermission,
|
||||
TOrganizationFeedbackDirectory,
|
||||
TOrganizationWorkspace,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -26,6 +27,7 @@ interface EditAPIKeysProps {
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
workspaces: TOrganizationWorkspace[];
|
||||
feedbackDirectories: TOrganizationFeedbackDirectory[];
|
||||
}
|
||||
|
||||
export const EditAPIKeys = ({
|
||||
@@ -34,6 +36,7 @@ export const EditAPIKeys = ({
|
||||
locale,
|
||||
isReadOnly,
|
||||
workspaces,
|
||||
feedbackDirectories,
|
||||
}: EditAPIKeysProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isAddAPIKeyModalOpen, setIsAddAPIKeyModalOpen] = useState(false);
|
||||
@@ -73,6 +76,10 @@ export const EditAPIKeys = ({
|
||||
permission: ApiKeyPermission;
|
||||
workspaceId: string;
|
||||
}>;
|
||||
feedbackDirectoryPermissions: Array<{
|
||||
permission: ApiKeyPermission;
|
||||
feedbackDirectoryId: string;
|
||||
}>;
|
||||
organizationAccess: TOrganizationAccess;
|
||||
}): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
@@ -81,6 +88,7 @@ export const EditAPIKeys = ({
|
||||
apiKeyData: {
|
||||
label: data.label,
|
||||
workspacePermissions: data.workspacePermissions,
|
||||
feedbackDirectoryPermissions: data.feedbackDirectoryPermissions,
|
||||
organizationAccess: data.organizationAccess,
|
||||
},
|
||||
});
|
||||
@@ -237,6 +245,7 @@ export const EditAPIKeys = ({
|
||||
setOpen={setIsAddAPIKeyModalOpen}
|
||||
onSubmit={handleAddAPIKey}
|
||||
workspaces={workspaces}
|
||||
feedbackDirectories={feedbackDirectories}
|
||||
isCreatingAPIKey={isLoading}
|
||||
/>
|
||||
{activeKey && (
|
||||
@@ -246,6 +255,7 @@ export const EditAPIKeys = ({
|
||||
onSubmit={handleUpdateAPIKey}
|
||||
apiKey={activeKey}
|
||||
workspaces={workspaces}
|
||||
feedbackDirectories={feedbackDirectories}
|
||||
isUpdating={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/
|
||||
import {
|
||||
type TApiKeyUpdateInput,
|
||||
type TApiKeyWithEnvironmentPermission,
|
||||
TOrganizationFeedbackDirectory,
|
||||
ZApiKeyUpdateInput,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -31,6 +32,7 @@ interface ViewPermissionModalProps {
|
||||
onSubmit: (data: TApiKeyUpdateInput) => Promise<void>;
|
||||
apiKey: TApiKeyWithEnvironmentPermission;
|
||||
workspaces: TOrganizationWorkspace[];
|
||||
feedbackDirectories: TOrganizationFeedbackDirectory[];
|
||||
isUpdating: boolean;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ export const ViewPermissionModal = ({
|
||||
onSubmit,
|
||||
apiKey,
|
||||
workspaces,
|
||||
feedbackDirectories,
|
||||
isUpdating,
|
||||
}: ViewPermissionModalProps) => {
|
||||
const { register, getValues, handleSubmit, reset, watch } = useForm<TApiKeyUpdateInput>({
|
||||
@@ -73,6 +76,11 @@ export const ViewPermissionModal = ({
|
||||
return name ?? `${t("workspace.api_keys.unknown_workspace")} (${workspaceId})`;
|
||||
};
|
||||
|
||||
const getDirectoryName = (directoryId: string) => {
|
||||
const name = feedbackDirectories.find((d) => d.id === directoryId)?.name;
|
||||
return name ?? `${t("workspace.api_keys.unknown_directory")} (${directoryId})`;
|
||||
};
|
||||
|
||||
const updateApiKey = async () => {
|
||||
const data = getValues();
|
||||
await onSubmit(data);
|
||||
@@ -146,6 +154,50 @@ export const ViewPermissionModal = ({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("workspace.api_keys.feedback_directory_access")}</Label>
|
||||
{apiKey.apiKeyFeedbackDirectories?.length === 0 && (
|
||||
<div className="text-center text-sm">
|
||||
{t("workspace.api_keys.no_directory_permissions_found")}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{apiKey.apiKeyFeedbackDirectories?.map((permission) => (
|
||||
<div key={permission.feedbackDirectoryId} className="flex items-center gap-2">
|
||||
<div className="w-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
|
||||
<span className="flex w-4/5 flex-1">
|
||||
<span className="w-full truncate text-left">
|
||||
{getDirectoryName(permission.feedbackDirectoryId)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
|
||||
<span className="flex w-4/5 flex-1">
|
||||
<span className="w-full truncate text-left capitalize">
|
||||
{permission.permission}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Label>{t("workspace.api_keys.organization_access")}</Label>
|
||||
{Object.keys(organizationAccess).map((key) => (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSecret, hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
@@ -38,6 +38,12 @@ export const getApiKeysWithEnvironmentPermissions = reactCache(
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackDirectories: {
|
||||
select: {
|
||||
permission: true,
|
||||
feedbackDirectoryId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return apiKeys;
|
||||
@@ -65,6 +71,16 @@ export const getApiKeyWithPermissions = reactCache(
|
||||
},
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackDirectories: {
|
||||
include: {
|
||||
feedbackDirectory: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Try v2 format first (fbk_{secret})
|
||||
@@ -156,6 +172,10 @@ export const createApiKey = async (
|
||||
workspaceId: string;
|
||||
permission: ApiKeyPermission;
|
||||
}>;
|
||||
feedbackDirectoryPermissions?: Array<{
|
||||
feedbackDirectoryId: string;
|
||||
permission: ApiKeyPermission;
|
||||
}>;
|
||||
organizationAccess: TOrganizationAccess;
|
||||
}
|
||||
): Promise<TApiKeyWithEnvironmentPermission & { actualKey: string }> => {
|
||||
@@ -171,7 +191,22 @@ export const createApiKey = async (
|
||||
// 2. bcrypt hash
|
||||
const hashedKey = await hashSecret(secret, 12);
|
||||
|
||||
const { workspacePermissions, organizationAccess, ...apiKeyDataWithoutPermissions } = apiKeyData;
|
||||
const {
|
||||
workspacePermissions,
|
||||
feedbackDirectoryPermissions,
|
||||
organizationAccess,
|
||||
...apiKeyDataWithoutPermissions
|
||||
} = apiKeyData;
|
||||
|
||||
if (feedbackDirectoryPermissions && feedbackDirectoryPermissions.length > 0) {
|
||||
const directoryIds = feedbackDirectoryPermissions.map((p) => p.feedbackDirectoryId);
|
||||
const ownedCount = await prisma.feedbackDirectory.count({
|
||||
where: { id: { in: directoryIds }, organizationId, isArchived: false },
|
||||
});
|
||||
if (ownedCount !== directoryIds.length) {
|
||||
throw new ResourceNotFoundError("FeedbackDirectory", null);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the API key
|
||||
const result = await prisma.apiKey.create({
|
||||
@@ -192,9 +227,20 @@ export const createApiKey = async (
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(feedbackDirectoryPermissions && feedbackDirectoryPermissions.length > 0
|
||||
? {
|
||||
apiKeyFeedbackDirectories: {
|
||||
create: feedbackDirectoryPermissions.map((dirPerm) => ({
|
||||
permission: dirPerm.permission,
|
||||
feedbackDirectoryId: dirPerm.feedbackDirectoryId,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
apiKeyWorkspaces: true,
|
||||
apiKeyFeedbackDirectories: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiKey, ApiKeyPermission, Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
|
||||
import {
|
||||
createApiKey,
|
||||
@@ -36,6 +36,7 @@ const mockApiKeyWithEnvironments: TApiKeyWithEnvironmentPermission = {
|
||||
permission: ApiKeyPermission.manage,
|
||||
},
|
||||
],
|
||||
apiKeyFeedbackDirectories: [],
|
||||
};
|
||||
|
||||
// Mock modules before tests
|
||||
@@ -49,6 +50,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
feedbackDirectory: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -115,6 +119,12 @@ describe("API Key Management", () => {
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackDirectories: {
|
||||
select: {
|
||||
permission: true,
|
||||
feedbackDirectoryId: true,
|
||||
},
|
||||
},
|
||||
createdAt: true,
|
||||
id: true,
|
||||
label: true,
|
||||
@@ -330,7 +340,7 @@ describe("API Key Management", () => {
|
||||
await expect(getApiKeyWithPermissions("fbk_testSecret123")).rejects.toThrow(errToThrow);
|
||||
});
|
||||
|
||||
test("uses workspace include without feedback directory relations in v2 lookup", async () => {
|
||||
test("includes apiKeyFeedbackDirectories with nested directory in v2 lookup", async () => {
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: new Date(Date.now() - 1000 * 10),
|
||||
@@ -348,11 +358,18 @@ describe("API Key Management", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackDirectories: {
|
||||
include: {
|
||||
feedbackDirectory: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("uses workspace include without feedback directory relations in legacy lookup", async () => {
|
||||
test("includes apiKeyFeedbackDirectories with nested directory in legacy lookup", async () => {
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: new Date(Date.now() - 1000 * 10),
|
||||
@@ -370,9 +387,40 @@ describe("API Key Management", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
apiKeyFeedbackDirectories: {
|
||||
include: {
|
||||
feedbackDirectory: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns directory permissions on the api key payload", async () => {
|
||||
const payload = {
|
||||
...mockApiKey,
|
||||
lastUsedAt: new Date(Date.now() - 1000 * 10),
|
||||
apiKeyWorkspaces: [],
|
||||
apiKeyFeedbackDirectories: [
|
||||
{
|
||||
id: "dir-perm-1",
|
||||
apiKeyId: "apikey123",
|
||||
feedbackDirectoryId: "dir1",
|
||||
permission: ApiKeyPermission.read,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
feedbackDirectory: { id: "dir1", name: "Directory 1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce(payload as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_testSecret123");
|
||||
|
||||
expect(result?.apiKeyFeedbackDirectories).toEqual(payload.apiKeyFeedbackDirectories);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteApiKey", () => {
|
||||
@@ -447,6 +495,7 @@ describe("API Key Management", () => {
|
||||
}),
|
||||
include: {
|
||||
apiKeyWorkspaces: true,
|
||||
apiKeyFeedbackDirectories: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -463,6 +512,108 @@ describe("API Key Management", () => {
|
||||
expect(prisma.apiKey.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates an API key with feedback directory permissions", async () => {
|
||||
vi.mocked(prisma.feedbackDirectory.count).mockResolvedValueOnce(2);
|
||||
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
|
||||
|
||||
await createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
feedbackDirectoryPermissions: [
|
||||
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
|
||||
{ feedbackDirectoryId: "dir2", permission: ApiKeyPermission.write },
|
||||
],
|
||||
});
|
||||
|
||||
expect(prisma.feedbackDirectory.count).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["dir1", "dir2"] }, organizationId: "org123", isArchived: false },
|
||||
});
|
||||
|
||||
expect(prisma.apiKey.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
apiKeyFeedbackDirectories: {
|
||||
create: [
|
||||
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
|
||||
{ feedbackDirectoryId: "dir2", permission: ApiKeyPermission.write },
|
||||
],
|
||||
},
|
||||
}),
|
||||
include: {
|
||||
apiKeyWorkspaces: true,
|
||||
apiKeyFeedbackDirectories: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("omits apiKeyFeedbackDirectories when feedbackDirectoryPermissions is empty", async () => {
|
||||
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
|
||||
|
||||
await createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
feedbackDirectoryPermissions: [],
|
||||
});
|
||||
|
||||
const callArg = vi.mocked(prisma.apiKey.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(callArg.data.apiKeyFeedbackDirectories).toBeUndefined();
|
||||
});
|
||||
|
||||
test("creates an API key with both workspace and directory permissions", async () => {
|
||||
vi.mocked(prisma.feedbackDirectory.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
|
||||
|
||||
await createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
workspacePermissions: [{ workspaceId: "workspace123", permission: ApiKeyPermission.manage }],
|
||||
feedbackDirectoryPermissions: [{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.manage }],
|
||||
});
|
||||
|
||||
expect(prisma.apiKey.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
apiKeyWorkspaces: {
|
||||
create: [{ workspaceId: "workspace123", permission: ApiKeyPermission.manage }],
|
||||
},
|
||||
apiKeyFeedbackDirectories: {
|
||||
create: [{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.manage }],
|
||||
},
|
||||
}),
|
||||
include: {
|
||||
apiKeyWorkspaces: true,
|
||||
apiKeyFeedbackDirectories: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects when a feedbackDirectoryId is not owned by the organization", async () => {
|
||||
vi.mocked(prisma.feedbackDirectory.count).mockResolvedValueOnce(1);
|
||||
|
||||
await expect(
|
||||
createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
feedbackDirectoryPermissions: [
|
||||
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
|
||||
{ feedbackDirectoryId: "foreign-dir", permission: ApiKeyPermission.read },
|
||||
],
|
||||
})
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
expect(prisma.feedbackDirectory.count).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["dir1", "foreign-dir"] }, organizationId: "org123", isArchived: false },
|
||||
});
|
||||
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects create input with duplicate feedbackDirectoryId", async () => {
|
||||
await expect(
|
||||
createApiKey("org123", "user123", {
|
||||
...mockApiKeyData,
|
||||
feedbackDirectoryPermissions: [
|
||||
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
|
||||
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.manage },
|
||||
],
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects create input with duplicate workspaceId", async () => {
|
||||
await expect(
|
||||
createApiKey("org123", "user123", {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TOrganizationFeedbackDirectory } from "../types/api-keys";
|
||||
import { getFeedbackDirectoriesByOrganizationId } from "./feedback-directories";
|
||||
|
||||
const mockDirectories: TOrganizationFeedbackDirectory[] = [
|
||||
{
|
||||
id: "dir1",
|
||||
name: "Directory 1",
|
||||
},
|
||||
{
|
||||
id: "dir2",
|
||||
name: "Directory 2",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
feedbackDirectory: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Feedback Directories Management", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getFeedbackDirectoriesByOrganizationId", () => {
|
||||
test("retrieves non-archived directories by organization ID successfully", async () => {
|
||||
vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValueOnce(
|
||||
mockDirectories as unknown as Awaited<ReturnType<typeof prisma.feedbackDirectory.findMany>>
|
||||
);
|
||||
|
||||
const result = await getFeedbackDirectoriesByOrganizationId("org123");
|
||||
|
||||
expect(result).toEqual(mockDirectories);
|
||||
expect(prisma.feedbackDirectory.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org123",
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no directories exist", async () => {
|
||||
vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getFeedbackDirectoriesByOrganizationId("org123");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.feedbackDirectory.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org123",
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on prisma known request error", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackDirectory.findMany).mockRejectedValueOnce(errToThrow);
|
||||
|
||||
await expect(getFeedbackDirectoriesByOrganizationId("org123")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("bubbles up unexpected errors", async () => {
|
||||
const unexpectedError = new Error("Unexpected error");
|
||||
vi.mocked(prisma.feedbackDirectory.findMany).mockRejectedValueOnce(unexpectedError);
|
||||
|
||||
await expect(getFeedbackDirectoriesByOrganizationId("org123")).rejects.toThrow(unexpectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TOrganizationFeedbackDirectory } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
|
||||
export const getFeedbackDirectoriesByOrganizationId = reactCache(
|
||||
async (organizationId: string): Promise<TOrganizationFeedbackDirectory[]> => {
|
||||
try {
|
||||
const directories = await prisma.feedbackDirectory.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return directories;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -2,6 +2,7 @@ import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/comp
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackDirectoriesByOrganizationId } from "@/modules/organization/settings/api-keys/lib/feedback-directories";
|
||||
import { getWorkspacesByOrganizationId } from "@/modules/organization/settings/api-keys/lib/workspaces";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -14,8 +15,9 @@ export const APIKeysPage = async (props: { params: Promise<{ workspaceId: string
|
||||
|
||||
const { currentUserMembership, organization, session } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
const [workspaces, locale] = await Promise.all([
|
||||
const [workspaces, feedbackDirectories, locale] = await Promise.all([
|
||||
getWorkspacesByOrganizationId(organization.id),
|
||||
getFeedbackDirectoriesByOrganizationId(organization.id),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
@@ -34,6 +36,7 @@ export const APIKeysPage = async (props: { params: Promise<{ workspaceId: string
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
isReadOnly={!canAccessApiKeys}
|
||||
workspaces={workspaces}
|
||||
feedbackDirectories={feedbackDirectories}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -8,10 +8,16 @@ export const ZApiKeyWorkspacePermission = z.object({
|
||||
permission: z.enum(ApiKeyPermission),
|
||||
});
|
||||
|
||||
export const ZApiKeyFeedbackDirectoryPermission = z.object({
|
||||
feedbackDirectoryId: z.string(),
|
||||
permission: z.enum(ApiKeyPermission),
|
||||
});
|
||||
|
||||
export const ZApiKeyCreateInput = z
|
||||
.object({
|
||||
label: z.string(),
|
||||
workspacePermissions: z.array(ZApiKeyWorkspacePermission).optional(),
|
||||
feedbackDirectoryPermissions: z.array(ZApiKeyFeedbackDirectoryPermission).optional(),
|
||||
organizationAccess: ZOrganizationAccess,
|
||||
})
|
||||
.refine(
|
||||
@@ -21,6 +27,17 @@ export const ZApiKeyCreateInput = z
|
||||
return new Set(ids).size === ids.length;
|
||||
},
|
||||
{ message: "Duplicate workspace permissions are not allowed", path: ["workspacePermissions"] }
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.feedbackDirectoryPermissions) return true;
|
||||
const ids = data.feedbackDirectoryPermissions.map((p) => p.feedbackDirectoryId);
|
||||
return new Set(ids).size === ids.length;
|
||||
},
|
||||
{
|
||||
message: "Duplicate feedback directory permissions are not allowed",
|
||||
path: ["feedbackDirectoryPermissions"],
|
||||
}
|
||||
);
|
||||
|
||||
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;
|
||||
@@ -44,13 +61,23 @@ export type TOrganizationWorkspace = z.infer<typeof OrganizationWorkspace>;
|
||||
|
||||
export type TApiKeyWorkspacePermission = z.infer<typeof ZApiKeyWorkspacePermission>;
|
||||
|
||||
export type TApiKeyFeedbackDirectoryPermission = z.infer<typeof ZApiKeyFeedbackDirectoryPermission>;
|
||||
|
||||
export interface TApiKeyWithEnvironmentPermission extends Pick<
|
||||
ApiKey,
|
||||
"id" | "label" | "createdAt" | "organizationAccess"
|
||||
> {
|
||||
apiKeyWorkspaces: TApiKeyWorkspacePermission[];
|
||||
apiKeyFeedbackDirectories: TApiKeyFeedbackDirectoryPermission[];
|
||||
}
|
||||
|
||||
export const OrganizationFeedbackDirectory = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export type TOrganizationFeedbackDirectory = z.infer<typeof OrganizationFeedbackDirectory>;
|
||||
|
||||
const ZApiKeyWorkspaceWithWorkspace = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@@ -61,6 +88,19 @@ const ZApiKeyWorkspaceWithWorkspace = z.object({
|
||||
workspace: ZWorkspace.pick({ id: true, name: true }),
|
||||
});
|
||||
|
||||
const ZApiKeyFeedbackDirectoryWithDirectory = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
apiKeyId: z.string(),
|
||||
feedbackDirectoryId: z.string(),
|
||||
permission: z.enum(ApiKeyPermission),
|
||||
feedbackDirectory: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const ZApiKey = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@@ -76,6 +116,7 @@ const ZApiKey = z.object({
|
||||
|
||||
export const ZApiKeyWithEnvironmentAndWorkspace = ZApiKey.extend({
|
||||
apiKeyWorkspaces: z.array(ZApiKeyWorkspaceWithWorkspace),
|
||||
apiKeyFeedbackDirectories: z.array(ZApiKeyFeedbackDirectoryWithDirectory),
|
||||
});
|
||||
|
||||
export type TApiKeyWithEnvironmentAndWorkspace = z.infer<typeof ZApiKeyWithEnvironmentAndWorkspace>;
|
||||
|
||||
@@ -54,6 +54,38 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
|
||||
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
|
||||
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
|
||||
|
||||
## Hub worker and self-hosted embeddings
|
||||
|
||||
The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is insert-only for River jobs; webhook dispatch and embedding jobs are processed by `hub-worker`.
|
||||
|
||||
Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub:
|
||||
|
||||
```yaml
|
||||
hub:
|
||||
worker:
|
||||
enabled: true
|
||||
|
||||
embeddings:
|
||||
enabled: true
|
||||
model: google/embeddinggemma-300m
|
||||
servedModelName: google/embeddinggemma-300m
|
||||
huggingFace:
|
||||
token: hf_...
|
||||
```
|
||||
|
||||
The generated Hub embedding configuration is:
|
||||
|
||||
- `EMBEDDING_PROVIDER=openai`
|
||||
- `EMBEDDING_MODEL=<hub.embeddings.servedModelName or hub.embeddings.model>`
|
||||
- `EMBEDDING_BASE_URL=http://<release>-hub-embeddings:8080/v1`
|
||||
- `EMBEDDING_PROVIDER_API_KEY` from a dedicated embeddings Secret
|
||||
|
||||
The TEI service is internal-only (`ClusterIP`) and not exposed through ingress. For gated models such as `google/embeddinggemma-300m`, provide `hub.embeddings.huggingFace.token` or set `hub.embeddings.huggingFace.existingSecret`.
|
||||
|
||||
When TEI auth is enabled, configure the shared key through `hub.embeddings.auth.apiKey` or `hub.embeddings.auth.existingSecret`; the chart manages both TEI `API_KEY` and Hub `EMBEDDING_PROVIDER_API_KEY` from that source.
|
||||
|
||||
Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If you scale the embeddings runtime above one replica while persistence is enabled, the cache PVC must support `ReadWriteMany`; otherwise set `hub.embeddings.persistence.enabled=false` or provide a compatible `existingClaim`.
|
||||
|
||||
## Values
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
@@ -139,7 +171,40 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
|
||||
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
|
||||
| formbricks.publicUrl | string | `""` | |
|
||||
| formbricks.webappUrl | string | `""` | |
|
||||
| hub.autoscaling.enabled | bool | `false` | |
|
||||
| hub.autoscaling.maxReplicas | int | `3` | |
|
||||
| hub.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.enabled | bool | `true` | |
|
||||
| hub.embeddings.auth.enabled | bool | `true` | |
|
||||
| hub.embeddings.auth.existingSecret | string | `""` | |
|
||||
| hub.embeddings.auth.secretKey | string | `"EMBEDDING_PROVIDER_API_KEY"` | |
|
||||
| hub.embeddings.autoscaling.enabled | bool | `false` | |
|
||||
| hub.embeddings.autoscaling.maxReplicas | int | `2` | |
|
||||
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
|
||||
| hub.embeddings.enabled | bool | `false` | |
|
||||
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
|
||||
| hub.embeddings.huggingFace.token | string | `""` | |
|
||||
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
|
||||
| hub.embeddings.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| hub.embeddings.image.repository | string | `"ghcr.io/huggingface/text-embeddings-inference"` | |
|
||||
| hub.embeddings.image.tag | string | `"cpu-1.9"` | |
|
||||
| hub.embeddings.maxConcurrent | string | `"5"` | |
|
||||
| hub.embeddings.model | string | `"google/embeddinggemma-300m"` | |
|
||||
| hub.embeddings.persistence.enabled | bool | `true` | |
|
||||
| hub.embeddings.persistence.mountPath | string | `"/data"` | |
|
||||
| hub.embeddings.persistence.size | string | `"10Gi"` | |
|
||||
| hub.embeddings.pdb.enabled | bool | `false` | |
|
||||
| hub.embeddings.port | int | `8080` | |
|
||||
| hub.embeddings.prometheusPort | int | `9000` | |
|
||||
| hub.embeddings.replicas | int | `1` | |
|
||||
| hub.embeddings.resources.limits.memory | string | `"8Gi"` | |
|
||||
| hub.embeddings.resources.requests.cpu | string | `"4"` | |
|
||||
| hub.embeddings.resources.requests.memory | string | `"8Gi"` | |
|
||||
| hub.embeddings.runtime | string | `"tei"` | |
|
||||
| hub.embeddings.servedModelName | string | `""` | Defaults to `hub.embeddings.model`. |
|
||||
| hub.embeddings.service.port | int | `8080` | |
|
||||
| hub.embeddings.service.type | string | `"ClusterIP"` | |
|
||||
| hub.env | object | `{}` | |
|
||||
| hub.existingSecret | string | `""` | |
|
||||
| hub.image.digest | string | `"sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"` | When set, takes precedence over tag (immutable pin). |
|
||||
@@ -149,10 +214,21 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
|
||||
| hub.migration.activeDeadlineSeconds | int | `900` | |
|
||||
| hub.migration.backoffLimit | int | `3` | |
|
||||
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
|
||||
| hub.pdb.enabled | bool | `false` | |
|
||||
| hub.replicas | int | `1` | |
|
||||
| hub.resources.limits.memory | string | `"512Mi"` | |
|
||||
| hub.resources.requests.cpu | string | `"100m"` | |
|
||||
| hub.resources.requests.memory | string | `"256Mi"` | |
|
||||
| hub.worker.autoscaling.enabled | bool | `false` | |
|
||||
| hub.worker.autoscaling.maxReplicas | int | `5` | |
|
||||
| hub.worker.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.worker.enabled | bool | `true` | |
|
||||
| hub.worker.env | object | `{}` | |
|
||||
| hub.worker.pdb.enabled | bool | `false` | |
|
||||
| hub.worker.replicas | int | `1` | |
|
||||
| hub.worker.resources.limits.memory | string | `"512Mi"` | |
|
||||
| hub.worker.resources.requests.cpu | string | `"100m"` | |
|
||||
| hub.worker.resources.requests.memory | string | `"256Mi"` | |
|
||||
| ingress.annotations | object | `{}` | |
|
||||
| ingress.enabled | bool | `false` | |
|
||||
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
|
||||
|
||||
@@ -114,6 +114,105 @@ hub-worker) must use this helper so they cannot drift apart.
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Hub worker resource name.
|
||||
*/}}
|
||||
{{- define "formbricks.hubWorkerName" -}}
|
||||
{{- $base := include "formbricks.name" . | trunc 52 | trimSuffix "-" }}
|
||||
{{- printf "%s-hub-worker" $base | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Hub embeddings runtime resource name.
|
||||
*/}}
|
||||
{{- define "formbricks.hubEmbeddingsName" -}}
|
||||
{{- $base := include "formbricks.name" . | trunc 48 | trimSuffix "-" }}
|
||||
{{- printf "%s-hub-embeddings" $base | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Secret used by Hub and the embeddings runtime for the embeddings API key.
|
||||
*/}}
|
||||
{{- define "formbricks.hubEmbeddingsSecretName" -}}
|
||||
{{- default (printf "%s-secret" (include "formbricks.hubEmbeddingsName" .)) .Values.hub.embeddings.auth.existingSecret -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Secret used by the embeddings runtime for Hugging Face access.
|
||||
*/}}
|
||||
{{- define "formbricks.hubEmbeddingsHuggingFaceSecretName" -}}
|
||||
{{- default (include "formbricks.hubEmbeddingsSecretName" .) .Values.hub.embeddings.huggingFace.existingSecret -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Model name Hub sends to the OpenAI-compatible embeddings endpoint.
|
||||
*/}}
|
||||
{{- define "formbricks.hubEmbeddingsServedModelName" -}}
|
||||
{{- default .Values.hub.embeddings.model .Values.hub.embeddings.servedModelName -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
OpenAI-compatible embeddings base URL used by Hub.
|
||||
*/}}
|
||||
{{- define "formbricks.hubEmbeddingsBaseURL" -}}
|
||||
{{- if .Values.hub.embeddings.baseUrl -}}
|
||||
{{- .Values.hub.embeddings.baseUrl -}}
|
||||
{{- else -}}
|
||||
{{- printf "http://%s:%v/v1" (include "formbricks.hubEmbeddingsName" .) (.Values.hub.embeddings.service.port | default .Values.hub.embeddings.port) -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Embedding API key value for the generated embeddings secret.
|
||||
*/}}
|
||||
{{- define "formbricks.hubEmbeddingsApiKey" -}}
|
||||
{{- $secretName := include "formbricks.hubEmbeddingsSecretName" . }}
|
||||
{{- $secretKey := .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
|
||||
{{- if and $secret (index $secret.data $secretKey) }}
|
||||
{{- index $secret.data $secretKey | b64dec -}}
|
||||
{{- else if .Values.hub.embeddings.auth.apiKey }}
|
||||
{{- .Values.hub.embeddings.auth.apiKey -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Shared Hub embedding env. These values are managed from hub.embeddings when the
|
||||
self-hosted runtime is enabled so Hub API and Hub worker cannot drift.
|
||||
*/}}
|
||||
{{- define "formbricks.hubEmbeddingEnv" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- if $root.Values.hub.embeddings.enabled }}
|
||||
- name: EMBEDDING_PROVIDER
|
||||
value: "openai"
|
||||
- name: EMBEDDING_MODEL
|
||||
value: {{ include "formbricks.hubEmbeddingsServedModelName" $root | quote }}
|
||||
- name: EMBEDDING_BASE_URL
|
||||
value: {{ include "formbricks.hubEmbeddingsBaseURL" $root | quote }}
|
||||
- name: EMBEDDING_PROVIDER_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "formbricks.hubEmbeddingsSecretName" $root }}
|
||||
key: {{ $root.Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
|
||||
- name: EMBEDDING_MAX_CONCURRENT
|
||||
value: {{ $root.Values.hub.embeddings.maxConcurrent | quote }}
|
||||
- name: EMBEDDING_NORMALIZE
|
||||
value: {{ $root.Values.hub.embeddings.normalize | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Returns true when an env var is managed by hub.embeddings and should not be rendered from hub.env/worker.env.
|
||||
*/}}
|
||||
{{- define "formbricks.hubEmbeddingEnvManaged" -}}
|
||||
{{- $key := .key -}}
|
||||
{{- if has $key (list "EMBEDDING_PROVIDER" "EMBEDDING_MODEL" "EMBEDDING_BASE_URL" "EMBEDDING_PROVIDER_API_KEY" "EMBEDDING_MAX_CONCURRENT" "EMBEDDING_NORMALIZE") -}}
|
||||
true
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
|
||||
{{- define "formbricks.postgresAdminPassword" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
@@ -142,13 +241,13 @@ hub-worker) must use this helper so they cannot drift apart.
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.cronSecret" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if $secret }}
|
||||
{{- index $secret.data "CRON_SECRET" | b64dec -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
{{- define "formbricks.cronSecret" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if $secret }}
|
||||
{{- index $secret.data "CRON_SECRET" | b64dec -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.encryptionKey" -}}
|
||||
|
||||
@@ -14,7 +14,9 @@ metadata:
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
spec:
|
||||
{{- if not .Values.hub.autoscaling.enabled }}
|
||||
replicas: {{ .Values.hub.replicas | default 1 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
@@ -66,10 +68,13 @@ spec:
|
||||
secretKeyRef:
|
||||
name: {{ include "formbricks.hubSecretName" . }}
|
||||
key: HUB_API_KEY
|
||||
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" .Values.hub.env) | nindent 12 }}
|
||||
{{- range $key, $value := .Values.hub.env }}
|
||||
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.hub.resources }}
|
||||
resources:
|
||||
{{- toYaml .Values.hub.resources | nindent 12 }}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled }}
|
||||
{{- $embeddingsReplicas := int (.Values.hub.embeddings.replicas | default 1) -}}
|
||||
{{- $embeddingsMaxReplicas := int (.Values.hub.embeddings.autoscaling.maxReplicas | default 1) -}}
|
||||
{{- if and .Values.hub.embeddings.persistence.enabled (not (has "ReadWriteMany" .Values.hub.embeddings.persistence.accessModes)) (or (gt $embeddingsReplicas 1) (and .Values.hub.embeddings.autoscaling.enabled (gt $embeddingsMaxReplicas 1))) }}
|
||||
{{- fail "hub.embeddings persistence with multiple replicas requires persistence.accessModes to include ReadWriteMany, or set hub.embeddings.persistence.enabled=false/use a ReadWriteMany existingClaim" }}
|
||||
{{- end }}
|
||||
{{- if and .Values.hub.embeddings.auth.existingSecret .Values.hub.embeddings.huggingFace.token (not .Values.hub.embeddings.huggingFace.existingSecret) }}
|
||||
{{- fail "hub.embeddings.huggingFace.token cannot be stored when hub.embeddings.auth.existingSecret is set; put HF_TOKEN in the existing auth secret or set hub.embeddings.huggingFace.existingSecret" }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-embeddings
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
spec:
|
||||
{{- if not .Values.hub.embeddings.autoscaling.enabled }}
|
||||
replicas: {{ .Values.hub.embeddings.replicas | default 1 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-embeddings
|
||||
{{- with .Values.hub.embeddings.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.hub.embeddings.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.deployment.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: hub-embeddings
|
||||
image: "{{ .Values.hub.embeddings.image.repository }}:{{ .Values.hub.embeddings.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.hub.embeddings.image.pullPolicy }}
|
||||
{{- with .Values.hub.embeddings.command }}
|
||||
command:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.hub.embeddings.args }}
|
||||
args:
|
||||
{{- toYaml .Values.hub.embeddings.args | nindent 12 }}
|
||||
{{- else }}
|
||||
args:
|
||||
- --model-id
|
||||
- {{ .Values.hub.embeddings.model | quote }}
|
||||
- --port
|
||||
- {{ .Values.hub.embeddings.port | quote }}
|
||||
- --huggingface-hub-cache
|
||||
- {{ .Values.hub.embeddings.persistence.mountPath | quote }}
|
||||
- --served-model-name
|
||||
- {{ include "formbricks.hubEmbeddingsServedModelName" . | quote }}
|
||||
{{- with .Values.hub.embeddings.revision }}
|
||||
- --revision
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.extraArgs }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.hub.embeddings.port }}
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
containerPort: {{ .Values.hub.embeddings.prometheusPort }}
|
||||
protocol: TCP
|
||||
{{- if or .Values.hub.embeddings.auth.enabled .Values.hub.embeddings.huggingFace.existingSecret .Values.hub.embeddings.huggingFace.token (gt (len .Values.hub.embeddings.env) 0) }}
|
||||
env:
|
||||
{{- if .Values.hub.embeddings.auth.enabled }}
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "formbricks.hubEmbeddingsSecretName" . }}
|
||||
key: {{ .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
|
||||
{{- end }}
|
||||
{{- if or .Values.hub.embeddings.huggingFace.existingSecret .Values.hub.embeddings.huggingFace.token }}
|
||||
- name: HF_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "formbricks.hubEmbeddingsHuggingFaceSecretName" . }}
|
||||
key: {{ .Values.hub.embeddings.huggingFace.tokenKey | default "HF_TOKEN" }}
|
||||
{{- end }}
|
||||
{{- range $key, $value := .Values.hub.embeddings.env }}
|
||||
{{- if not (or (and $.Values.hub.embeddings.auth.enabled (eq $key "API_KEY")) (and (or $.Values.hub.embeddings.huggingFace.existingSecret $.Values.hub.embeddings.huggingFace.token) (eq $key "HF_TOKEN"))) }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.probes.startupProbe }}
|
||||
startupProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.probes.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.probes.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.hub.embeddings.persistence.enabled }}
|
||||
volumeMounts:
|
||||
- name: model-cache
|
||||
mountPath: {{ .Values.hub.embeddings.persistence.mountPath }}
|
||||
{{- end }}
|
||||
{{- if .Values.hub.embeddings.persistence.enabled }}
|
||||
volumes:
|
||||
- name: model-cache
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ default (include "formbricks.hubEmbeddingsName" .) .Values.hub.embeddings.persistence.existingClaim }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.persistence.enabled (not .Values.hub.embeddings.persistence.existingClaim) }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-embeddings
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- toYaml .Values.hub.embeddings.persistence.accessModes | nindent 4 }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.hub.embeddings.persistence.size }}
|
||||
{{- with .Values.hub.embeddings.persistence.storageClass }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled (not .Values.hub.embeddings.auth.existingSecret) }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubEmbeddingsSecretName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-embeddings
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}: {{ include "formbricks.hubEmbeddingsApiKey" . | b64enc }}
|
||||
{{- if and (not .Values.hub.embeddings.huggingFace.existingSecret) .Values.hub.embeddings.huggingFace.token }}
|
||||
{{ .Values.hub.embeddings.huggingFace.tokenKey | default "HF_TOKEN" }}: {{ .Values.hub.embeddings.huggingFace.token | b64enc }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-embeddings
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
{{- with .Values.hub.embeddings.service.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.hub.embeddings.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.hub.embeddings.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
port: {{ .Values.hub.embeddings.prometheusPort }}
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
@@ -0,0 +1,102 @@
|
||||
{{- if and .Values.hub.enabled .Values.hub.autoscaling.enabled }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubname" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
{{- with .Values.hub.autoscaling.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.autoscaling.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "formbricks.hubname" . }}
|
||||
minReplicas: {{ .Values.hub.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.hub.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- toYaml .Values.hub.autoscaling.metrics | nindent 4 }}
|
||||
{{- with .Values.hub.autoscaling.behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if and .Values.hub.enabled .Values.hub.worker.enabled .Values.hub.worker.autoscaling.enabled }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubWorkerName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-worker
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
{{- with .Values.hub.worker.autoscaling.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.worker.autoscaling.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "formbricks.hubWorkerName" . }}
|
||||
minReplicas: {{ .Values.hub.worker.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.hub.worker.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- toYaml .Values.hub.worker.autoscaling.metrics | nindent 4 }}
|
||||
{{- with .Values.hub.worker.autoscaling.behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.autoscaling.enabled }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-embeddings
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
{{- with .Values.hub.embeddings.autoscaling.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.autoscaling.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
minReplicas: {{ .Values.hub.embeddings.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.hub.embeddings.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- toYaml .Values.hub.embeddings.autoscaling.metrics | nindent 4 }}
|
||||
{{- with .Values.hub.embeddings.autoscaling.behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,129 @@
|
||||
{{- if and .Values.hub.enabled .Values.hub.pdb.enabled }}
|
||||
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.pdb.minAvailable) -}}
|
||||
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.pdb.maxUnavailable) -}}
|
||||
{{- if and $hasMinAvailable $hasMaxUnavailable }}
|
||||
{{- fail "hub.pdb.minAvailable and hub.pdb.maxUnavailable are mutually exclusive; set only one" }}
|
||||
{{- end }}
|
||||
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
|
||||
{{- fail "hub.pdb.enabled is true but neither hub.pdb.minAvailable nor hub.pdb.maxUnavailable is set; set exactly one" }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubname" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
{{- with .Values.hub.pdb.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.pdb.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if $hasMinAvailable }}
|
||||
minAvailable: {{ .Values.hub.pdb.minAvailable }}
|
||||
{{- end }}
|
||||
{{- if $hasMaxUnavailable }}
|
||||
maxUnavailable: {{ .Values.hub.pdb.maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.pdb.unhealthyPodEvictionPolicy }}
|
||||
unhealthyPodEvictionPolicy: {{ . }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
{{- if and .Values.hub.enabled .Values.hub.worker.enabled .Values.hub.worker.pdb.enabled }}
|
||||
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.worker.pdb.minAvailable) -}}
|
||||
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.worker.pdb.maxUnavailable) -}}
|
||||
{{- if and $hasMinAvailable $hasMaxUnavailable }}
|
||||
{{- fail "hub.worker.pdb.minAvailable and hub.worker.pdb.maxUnavailable are mutually exclusive; set only one" }}
|
||||
{{- end }}
|
||||
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
|
||||
{{- fail "hub.worker.pdb.enabled is true but neither hub.worker.pdb.minAvailable nor hub.worker.pdb.maxUnavailable is set; set exactly one" }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubWorkerName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-worker
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
{{- with .Values.hub.worker.pdb.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.worker.pdb.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if $hasMinAvailable }}
|
||||
minAvailable: {{ .Values.hub.worker.pdb.minAvailable }}
|
||||
{{- end }}
|
||||
{{- if $hasMaxUnavailable }}
|
||||
maxUnavailable: {{ .Values.hub.worker.pdb.maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.worker.pdb.unhealthyPodEvictionPolicy }}
|
||||
unhealthyPodEvictionPolicy: {{ . }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.pdb.enabled }}
|
||||
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.embeddings.pdb.minAvailable) -}}
|
||||
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.embeddings.pdb.maxUnavailable) -}}
|
||||
{{- if and $hasMinAvailable $hasMaxUnavailable }}
|
||||
{{- fail "hub.embeddings.pdb.minAvailable and hub.embeddings.pdb.maxUnavailable are mutually exclusive; set only one" }}
|
||||
{{- end }}
|
||||
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
|
||||
{{- fail "hub.embeddings.pdb.enabled is true but neither hub.embeddings.pdb.minAvailable nor hub.embeddings.pdb.maxUnavailable is set; set exactly one" }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-embeddings
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
{{- with .Values.hub.embeddings.pdb.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.pdb.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if $hasMinAvailable }}
|
||||
minAvailable: {{ .Values.hub.embeddings.pdb.minAvailable }}
|
||||
{{- end }}
|
||||
{{- if $hasMaxUnavailable }}
|
||||
maxUnavailable: {{ .Values.hub.embeddings.pdb.maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.embeddings.pdb.unhealthyPodEvictionPolicy }}
|
||||
unhealthyPodEvictionPolicy: {{ . }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,97 @@
|
||||
{{- if and .Values.hub.enabled .Values.hub.worker.enabled }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubWorkerName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-worker
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
spec:
|
||||
{{- if not .Values.hub.worker.autoscaling.enabled }}
|
||||
replicas: {{ .Values.hub.worker.replicas | default 1 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-worker
|
||||
spec:
|
||||
{{- with .Values.hub.worker.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.worker.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.worker.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.hub.worker.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.deployment.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
initContainers:
|
||||
- name: wait-for-hub-api
|
||||
image: {{ include "formbricks.hubImage" . }}
|
||||
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until wget --no-verbose --tries=1 --spider http://{{ include "formbricks.hubname" . }}:8080/health; do
|
||||
echo "Waiting for Hub API migrations and health check..."
|
||||
sleep 5
|
||||
done
|
||||
containers:
|
||||
- name: hub-worker
|
||||
image: {{ include "formbricks.hubImage" . }}
|
||||
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
|
||||
command:
|
||||
- /app/hub-worker
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "formbricks.hubSecretName" . }}
|
||||
{{- if or .Values.hub.embeddings.enabled (gt (len .Values.hub.env) 0) (gt (len .Values.hub.worker.env) 0) }}
|
||||
env:
|
||||
{{- $workerEnv := merge (dict) .Values.hub.env .Values.hub.worker.env }}
|
||||
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" $workerEnv) | nindent 12 }}
|
||||
{{- range $key, $value := .Values.hub.env }}
|
||||
{{- if and (not (hasKey $.Values.hub.worker.env $key)) (not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key)))) }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range $key, $value := .Values.hub.worker.env }}
|
||||
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.hub.worker.resources }}
|
||||
resources:
|
||||
{{- toYaml .Values.hub.worker.resources | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -588,6 +588,241 @@ hub:
|
||||
# Optional env vars (non-secret). Use existingSecret for secret values such as DATABASE_URL and HUB_API_KEY.
|
||||
env: {}
|
||||
|
||||
# Optional autoscaling for the Hub API deployment.
|
||||
autoscaling:
|
||||
enabled: false
|
||||
additionalLabels: {}
|
||||
annotations: {}
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
behavior: {}
|
||||
|
||||
# Optional PDB for the Hub API deployment. Disabled by default because a single
|
||||
# Hub replica with minAvailable: 1 blocks voluntary node drains.
|
||||
pdb:
|
||||
enabled: false
|
||||
additionalLabels: {}
|
||||
annotations: {}
|
||||
minAvailable: 1
|
||||
# maxUnavailable: 1
|
||||
# unhealthyPodEvictionPolicy: AlwaysAllow
|
||||
|
||||
worker:
|
||||
# Hub async jobs (webhook dispatch, embeddings) run in hub-worker. Keep this
|
||||
# enabled unless another worker deployment processes the same River queues.
|
||||
enabled: true
|
||||
replicas: 1
|
||||
|
||||
# Optional env vars (non-secret) added only to hub-worker.
|
||||
env: {}
|
||||
|
||||
resources:
|
||||
limits:
|
||||
memory: 512Mi
|
||||
requests:
|
||||
memory: 256Mi
|
||||
cpu: "100m"
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
additionalLabels: {}
|
||||
annotations: {}
|
||||
minReplicas: 1
|
||||
maxReplicas: 5
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
policies:
|
||||
- type: Pods
|
||||
value: 1
|
||||
periodSeconds: 120
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 60
|
||||
policies:
|
||||
- type: Pods
|
||||
value: 2
|
||||
periodSeconds: 60
|
||||
|
||||
# Disabled by default because the default worker replica count is 1.
|
||||
pdb:
|
||||
enabled: false
|
||||
additionalLabels: {}
|
||||
annotations: {}
|
||||
minAvailable: 1
|
||||
# maxUnavailable: 1
|
||||
# unhealthyPodEvictionPolicy: AlwaysAllow
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
topologySpreadConstraints: []
|
||||
|
||||
embeddings:
|
||||
# Optional self-hosted OpenAI-compatible embeddings runtime for Hub.
|
||||
enabled: false
|
||||
runtime: tei
|
||||
model: google/embeddinggemma-300m
|
||||
revision: ""
|
||||
# Defaults to `model` when empty. Used by TEI OpenAI-compatible responses
|
||||
# and as Hub's EMBEDDING_MODEL.
|
||||
servedModelName: ""
|
||||
# Defaults to http://<release>-hub-embeddings:<service.port>/v1 when empty.
|
||||
baseUrl: ""
|
||||
maxConcurrent: "5"
|
||||
normalize: "false"
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: "ghcr.io/huggingface/text-embeddings-inference"
|
||||
tag: "cpu-1.9"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
command: []
|
||||
# When empty, the chart renders TEI args from model, servedModelName, port,
|
||||
# revision, and persistence.mountPath. Set this to fully override args.
|
||||
args: []
|
||||
extraArgs: []
|
||||
env: {}
|
||||
|
||||
port: 8080
|
||||
prometheusPort: 9000
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
annotations: {}
|
||||
additionalLabels: {}
|
||||
|
||||
auth:
|
||||
# TEI can enforce bearer-token auth with API_KEY. Hub always receives the
|
||||
# same key as EMBEDDING_PROVIDER_API_KEY because the OpenAI-compatible Hub
|
||||
# provider requires an API key.
|
||||
enabled: true
|
||||
existingSecret: ""
|
||||
secretKey: EMBEDDING_PROVIDER_API_KEY
|
||||
apiKey: ""
|
||||
|
||||
huggingFace:
|
||||
# Required for gated models such as google/embeddinggemma-300m unless the
|
||||
# model is pre-cached or a different ungated model is configured.
|
||||
existingSecret: ""
|
||||
tokenKey: HF_TOKEN
|
||||
token: ""
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
existingClaim: ""
|
||||
storageClass: ""
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
size: 10Gi
|
||||
mountPath: /data
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "4"
|
||||
memory: 8Gi
|
||||
limits:
|
||||
memory: 8Gi
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
additionalLabels: {}
|
||||
annotations: {}
|
||||
minReplicas: 1
|
||||
maxReplicas: 2
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 600
|
||||
policies:
|
||||
- type: Pods
|
||||
value: 1
|
||||
periodSeconds: 300
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 120
|
||||
policies:
|
||||
- type: Pods
|
||||
value: 1
|
||||
periodSeconds: 120
|
||||
|
||||
# Disabled by default because the default embeddings replica count is 1.
|
||||
pdb:
|
||||
enabled: false
|
||||
additionalLabels: {}
|
||||
annotations: {}
|
||||
minAvailable: 1
|
||||
# maxUnavailable: 1
|
||||
# unhealthyPodEvictionPolicy: AlwaysAllow
|
||||
|
||||
probes:
|
||||
startupProbe:
|
||||
failureThreshold: 60
|
||||
periodSeconds: 10
|
||||
tcpSocket:
|
||||
port: http
|
||||
readinessProbe:
|
||||
failureThreshold: 6
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
tcpSocket:
|
||||
port: http
|
||||
livenessProbe:
|
||||
failureThreshold: 6
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
tcpSocket:
|
||||
port: http
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
podSecurityContext: {}
|
||||
# Keep empty by default because upstream model-serving images may define
|
||||
# their own user and need write access to the model cache path.
|
||||
securityContext: {}
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
topologySpreadConstraints: []
|
||||
|
||||
# Helm does not deploy Cube. XM Suite v5 analytics requires operators to provide an external Cube instance,
|
||||
# set deployment.env.CUBEJS_API_URL, and supply CUBEJS_API_SECRET via an existing secret.
|
||||
|
||||
|
||||
@@ -83,6 +83,30 @@ describe("packages/ai provider helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("reports a fully configured Google Cloud instance when ADC provides credentials", () => {
|
||||
expect(
|
||||
getAiConfigurationStatus({
|
||||
AI_PROVIDER: "google",
|
||||
AI_MODEL: "gemini-2.5-flash",
|
||||
AI_GOOGLE_CLOUD_PROJECT: "test-project",
|
||||
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
|
||||
})
|
||||
).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-2.5-flash",
|
||||
isConfigured: true,
|
||||
missingFields: [],
|
||||
invalidFields: [],
|
||||
providerStatus: {
|
||||
provider: "google",
|
||||
model: "gemini-2.5-flash",
|
||||
isConfigured: true,
|
||||
missingFields: [],
|
||||
invalidFields: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("treats the instance as not configured when AI_PROVIDER is missing", () => {
|
||||
expect(
|
||||
isAiConfigured({
|
||||
@@ -207,6 +231,48 @@ describe("packages/ai provider helpers", () => {
|
||||
expect(mocks.createVertex).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("creates a Google Cloud model with application credentials file", () => {
|
||||
const vertexProvider = createMockProvider("google");
|
||||
mocks.createVertex.mockReturnValue(vertexProvider);
|
||||
|
||||
const model = getAiModel({
|
||||
AI_PROVIDER: "google",
|
||||
AI_MODEL: "gemini-2.5-flash",
|
||||
AI_GOOGLE_CLOUD_PROJECT: "test-project",
|
||||
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
|
||||
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: "/tmp/google-cloud.json",
|
||||
});
|
||||
|
||||
expect(model).toEqual({ providerName: "google", modelName: "gemini-2.5-flash" });
|
||||
expect(mocks.createVertex).toHaveBeenCalledWith({
|
||||
project: "test-project",
|
||||
location: "us-central1",
|
||||
googleAuthOptions: {
|
||||
keyFilename: "/tmp/google-cloud.json",
|
||||
},
|
||||
});
|
||||
expect(vertexProvider).toHaveBeenCalledWith("gemini-2.5-flash");
|
||||
});
|
||||
|
||||
test("creates a Google Cloud model using ADC when no credential override is configured", () => {
|
||||
const vertexProvider = createMockProvider("google");
|
||||
mocks.createVertex.mockReturnValue(vertexProvider);
|
||||
|
||||
const model = getAiModel({
|
||||
AI_PROVIDER: "google",
|
||||
AI_MODEL: "gemini-2.5-flash",
|
||||
AI_GOOGLE_CLOUD_PROJECT: "test-project",
|
||||
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
|
||||
});
|
||||
|
||||
expect(model).toEqual({ providerName: "google", modelName: "gemini-2.5-flash" });
|
||||
expect(mocks.createVertex).toHaveBeenCalledWith({
|
||||
project: "test-project",
|
||||
location: "us-central1",
|
||||
});
|
||||
expect(vertexProvider).toHaveBeenCalledWith("gemini-2.5-flash");
|
||||
});
|
||||
|
||||
test("creates an AWS model with explicit AWS credentials", () => {
|
||||
const bedrockProvider = createMockProvider("aws");
|
||||
mocks.createAmazonBedrock.mockReturnValue(bedrockProvider);
|
||||
|
||||
@@ -39,11 +39,6 @@ export const googleProviderAdapter: AIProviderAdapter = {
|
||||
}
|
||||
|
||||
const credentialsJson = normalizeValue(environment.AI_GOOGLE_CLOUD_CREDENTIALS_JSON);
|
||||
const applicationCredentials = normalizeValue(environment.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS);
|
||||
|
||||
if (!credentialsJson && !applicationCredentials) {
|
||||
missingFields.push("AI_GOOGLE_CLOUD_CREDENTIALS_JSON or AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS");
|
||||
}
|
||||
|
||||
if (credentialsJson) {
|
||||
try {
|
||||
@@ -73,15 +68,12 @@ export const googleProviderAdapter: AIProviderAdapter = {
|
||||
const credentialsJson = normalizeValue(environment.AI_GOOGLE_CLOUD_CREDENTIALS_JSON);
|
||||
const applicationCredentials = normalizeValue(environment.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS);
|
||||
|
||||
if (!project || !location || (!credentialsJson && !applicationCredentials)) {
|
||||
throw new AIConfigurationError("providerNotConfigured", "Google Cloud AI credentials are incomplete", {
|
||||
if (!project || !location) {
|
||||
throw new AIConfigurationError("providerNotConfigured", "Google Cloud AI configuration is incomplete", {
|
||||
provider: "google",
|
||||
missingFields: [
|
||||
...(!project ? ["AI_GOOGLE_CLOUD_PROJECT"] : []),
|
||||
...(!location ? ["AI_GOOGLE_CLOUD_LOCATION"] : []),
|
||||
...(!credentialsJson && !applicationCredentials
|
||||
? ["AI_GOOGLE_CLOUD_CREDENTIALS_JSON or AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS"]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ApiKeyFeedbackDirectory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"apiKeyId" TEXT NOT NULL,
|
||||
"feedbackDirectoryId" TEXT NOT NULL,
|
||||
"permission" "ApiKeyPermission" NOT NULL,
|
||||
|
||||
CONSTRAINT "ApiKeyFeedbackDirectory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ApiKeyFeedbackDirectory_apiKeyId_feedbackDirectoryId_key" ON "ApiKeyFeedbackDirectory"("apiKeyId", "feedbackDirectoryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ApiKeyFeedbackDirectory_feedbackDirectoryId_idx" ON "ApiKeyFeedbackDirectory"("feedbackDirectoryId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ApiKeyFeedbackDirectory" ADD CONSTRAINT "ApiKeyFeedbackDirectory_apiKeyId_fkey" FOREIGN KEY ("apiKeyId") REFERENCES "ApiKey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ApiKeyFeedbackDirectory" ADD CONSTRAINT "ApiKeyFeedbackDirectory_feedbackDirectoryId_fkey" FOREIGN KEY ("feedbackDirectoryId") REFERENCES "FeedbackDirectory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -757,18 +757,19 @@ model Invite {
|
||||
/// @property lastUsedAt - Timestamp of last usage
|
||||
/// @property apiKeyWorkspaces - Workspaces this key has access to
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
createdBy String?
|
||||
lastUsedAt DateTime?
|
||||
label String
|
||||
hashedKey String
|
||||
lookupHash String? @unique
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
apiKeyWorkspaces ApiKeyWorkspace[]
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
createdBy String?
|
||||
lastUsedAt DateTime?
|
||||
label String
|
||||
hashedKey String
|
||||
lookupHash String? @unique
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
apiKeyWorkspaces ApiKeyWorkspace[]
|
||||
apiKeyFeedbackDirectories ApiKeyFeedbackDirectory[]
|
||||
/// [OrganizationAccess]
|
||||
organizationAccess Json @default("{}")
|
||||
organizationAccess Json @default("{}")
|
||||
|
||||
@@index([organizationId])
|
||||
}
|
||||
@@ -802,6 +803,27 @@ model ApiKeyWorkspace {
|
||||
@@index([workspaceId])
|
||||
}
|
||||
|
||||
/// Links API keys to feedback directories with specific permissions.
|
||||
/// Enables granular access control for API keys across feedback directories (Hub API).
|
||||
///
|
||||
/// @property id - Unique identifier for the directory access entry
|
||||
/// @property apiKey - The associated API key
|
||||
/// @property feedbackDirectory - The directory being accessed
|
||||
/// @property permission - Level of access granted
|
||||
model ApiKeyFeedbackDirectory {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
apiKeyId String
|
||||
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
|
||||
feedbackDirectoryId String
|
||||
feedbackDirectory FeedbackDirectory @relation(fields: [feedbackDirectoryId], references: [id], onDelete: Cascade)
|
||||
permission ApiKeyPermission
|
||||
|
||||
@@unique([apiKeyId, feedbackDirectoryId])
|
||||
@@index([feedbackDirectoryId])
|
||||
}
|
||||
|
||||
enum IdentityProvider {
|
||||
email
|
||||
github
|
||||
@@ -1270,6 +1292,7 @@ model FeedbackDirectory {
|
||||
workspaces FeedbackDirectoryWorkspace[]
|
||||
connectors Connector[]
|
||||
charts Chart[]
|
||||
apiKeys ApiKeyFeedbackDirectory[]
|
||||
|
||||
@@unique([organizationId, name])
|
||||
}
|
||||
|
||||
@@ -15,9 +15,18 @@ export const ZAPIKeyWorkspacePermission = z.object({
|
||||
|
||||
export type TAPIKeyWorkspacePermission = z.infer<typeof ZAPIKeyWorkspacePermission>;
|
||||
|
||||
export const ZAPIKeyFeedbackDirectoryPermission = z.object({
|
||||
feedbackDirectoryId: z.cuid2(),
|
||||
feedbackDirectoryName: z.string(),
|
||||
permission: z.enum(ApiKeyPermission),
|
||||
});
|
||||
|
||||
export type TAPIKeyFeedbackDirectoryPermission = z.infer<typeof ZAPIKeyFeedbackDirectoryPermission>;
|
||||
|
||||
export const ZAuthenticationApiKey = z.object({
|
||||
type: z.literal("apiKey"),
|
||||
workspacePermissions: z.array(ZAPIKeyWorkspacePermission),
|
||||
feedbackDirectoryPermissions: z.array(ZAPIKeyFeedbackDirectoryPermission),
|
||||
apiKeyId: z.string(),
|
||||
organizationId: z.string(),
|
||||
organizationAccess: ZOrganizationAccess,
|
||||
|
||||
Reference in New Issue
Block a user