From 00fae937091a9a9b1b99acb7fc93d9cdad8f8715 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:15:19 +0000 Subject: [PATCH 01/12] ci(companion): add separate typecheck workflow to catch type errors This adds a new companion-typecheck.yml workflow that runs TypeScript type checking for the companion app. This would have caught the missing useEffect import issue in PR #26931. Changes: - Add new companion-typecheck.yml workflow file - Update pr.yml to call the new workflow when companion files change - Add typecheck-companion to the required jobs list Co-Authored-By: anik@cal.com --- .github/workflows/companion-typecheck.yml | 34 +++++++++++++++++++++++ .github/workflows/pr.yml | 13 ++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/companion-typecheck.yml diff --git a/.github/workflows/companion-typecheck.yml b/.github/workflows/companion-typecheck.yml new file mode 100644 index 00000000000000..bd4808ee48762c --- /dev/null +++ b/.github/workflows/companion-typecheck.yml @@ -0,0 +1,34 @@ +name: Companion Type Check + +on: + workflow_call: + +permissions: + contents: read + +jobs: + typecheck: + name: Type Check Companion App + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: .github + - uses: ./.github/actions/cache-checkout + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.0 + cache: true + cache-dependency-path: companion/bun.lock + + - name: Install dependencies + working-directory: companion + run: bun install --frozen-lockfile + + - name: Type check + working-directory: companion + run: bun run typecheck diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 552a0e59afd0ee..d70b4915559b78 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -377,6 +377,13 @@ jobs: uses: ./.github/workflows/companion-build.yml secrets: inherit + typecheck-companion: + name: Companion type check + needs: [prepare] + if: needs.prepare.outputs.has-companion == 'true' + uses: ./.github/workflows/companion-typecheck.yml + secrets: inherit + build: name: Production builds needs: [prepare] @@ -457,6 +464,7 @@ jobs: build-atoms, build-docs, build-companion, + typecheck-companion, setup-db, e2e, e2e-api-v2, @@ -506,5 +514,8 @@ jobs: ) || ( needs.prepare.outputs.has-companion == 'true' && - needs.build-companion.result != 'success' + ( + needs.build-companion.result != 'success' || + needs.typecheck-companion.result != 'success' + ) ) From f6f3c8fb08c85afa606d6fa2e3b18cc928b0a48e Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Sat, 17 Jan 2026 22:20:22 +0530 Subject: [PATCH 02/12] add lint checks --- .github/workflows/companion-lint.yml | 33 ++++++++++++++++++++++++++++ .github/workflows/pr.yml | 11 +++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/companion-lint.yml diff --git a/.github/workflows/companion-lint.yml b/.github/workflows/companion-lint.yml new file mode 100644 index 00000000000000..6b4ad4d57f00c9 --- /dev/null +++ b/.github/workflows/companion-lint.yml @@ -0,0 +1,33 @@ +name: Companion Lint + +on: + workflow_call: + +permissions: + contents: read + +jobs: + lint: + name: Lint Companion App + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: .github + - uses: ./.github/actions/cache-checkout + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.0 + cache: false + + - name: Install dependencies + working-directory: companion + run: bun install --frozen-lockfile + + - name: Run lint + working-directory: companion + run: bun run lint:all diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d70b4915559b78..8e974f6f359e13 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -384,6 +384,13 @@ jobs: uses: ./.github/workflows/companion-typecheck.yml secrets: inherit + lint-companion: + name: Companion lint + needs: [prepare] + if: needs.prepare.outputs.has-companion == 'true' + uses: ./.github/workflows/companion-lint.yml + secrets: inherit + build: name: Production builds needs: [prepare] @@ -465,6 +472,7 @@ jobs: build-docs, build-companion, typecheck-companion, + lint-companion, setup-db, e2e, e2e-api-v2, @@ -516,6 +524,7 @@ jobs: needs.prepare.outputs.has-companion == 'true' && ( needs.build-companion.result != 'success' || - needs.typecheck-companion.result != 'success' + needs.typecheck-companion.result != 'success' || + needs.lint-companion.result != 'success' ) ) From 52b8c2d0e6bcb22b113e5160fc890f80a279b053 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Sat, 17 Jan 2026 22:23:47 +0530 Subject: [PATCH 03/12] test --- companion/components/screens/AvailabilityDetailScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/companion/components/screens/AvailabilityDetailScreen.tsx b/companion/components/screens/AvailabilityDetailScreen.tsx index 085247606ddb23..3c33be78d8523d 100644 --- a/companion/components/screens/AvailabilityDetailScreen.tsx +++ b/companion/components/screens/AvailabilityDetailScreen.tsx @@ -8,7 +8,7 @@ import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useMemo } from "react"; import { ActivityIndicator, Alert, RefreshControl, ScrollView, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; From bd6f376df9df087097a66dbe1c7b1c79b3dbb330 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:27:36 +0530 Subject: [PATCH 04/12] remove --- companion/components/screens/AvailabilityDetailScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/companion/components/screens/AvailabilityDetailScreen.tsx b/companion/components/screens/AvailabilityDetailScreen.tsx index 3c33be78d8523d..085247606ddb23 100644 --- a/companion/components/screens/AvailabilityDetailScreen.tsx +++ b/companion/components/screens/AvailabilityDetailScreen.tsx @@ -8,7 +8,7 @@ import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; -import { forwardRef, useCallback, useImperativeHandle, useMemo } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo } from "react"; import { ActivityIndicator, Alert, RefreshControl, ScrollView, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; From 947173a3f5b455a0d0bad0d3689f2ac48bc8f009 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Mon, 19 Jan 2026 09:18:14 +0530 Subject: [PATCH 05/12] update typecheck --- .github/workflows/companion-typecheck.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/companion-typecheck.yml b/.github/workflows/companion-typecheck.yml index bd4808ee48762c..9911a9012cb941 100644 --- a/.github/workflows/companion-typecheck.yml +++ b/.github/workflows/companion-typecheck.yml @@ -31,4 +31,4 @@ jobs: - name: Type check working-directory: companion - run: bun run typecheck + run: bun run typecheck:all From 31f394b1763a2212d0c0d4bf00f4a1a469df2944 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Mon, 19 Jan 2026 20:00:04 +0530 Subject: [PATCH 06/12] address review --- .github/workflows/pr.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8e974f6f359e13..aa36e440edddf9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -299,7 +299,7 @@ jobs: skip-install-if-cache-hit: "true" type-check: - name: Type check + name: Type Checks needs: [prepare] if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/check-types.yml @@ -378,14 +378,14 @@ jobs: secrets: inherit typecheck-companion: - name: Companion type check + name: Type Checks needs: [prepare] if: needs.prepare.outputs.has-companion == 'true' uses: ./.github/workflows/companion-typecheck.yml secrets: inherit lint-companion: - name: Companion lint + name: Linters needs: [prepare] if: needs.prepare.outputs.has-companion == 'true' uses: ./.github/workflows/companion-lint.yml From 0943377b7520ca1545c00ea4acf9066c0cecd1d5 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Mon, 19 Jan 2026 22:38:06 +0530 Subject: [PATCH 07/12] chore:- hide apps with missing required keys from app store --- packages/app-store/_appRegistry.ts | 18 ++- .../_utils/hasRequiredAppKeys.test.ts | 132 ++++++++++++++++++ .../app-store/_utils/hasRequiredAppKeys.ts | 32 +++++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 packages/app-store/_utils/hasRequiredAppKeys.test.ts create mode 100644 packages/app-store/_utils/hasRequiredAppKeys.ts diff --git a/packages/app-store/_appRegistry.ts b/packages/app-store/_appRegistry.ts index 109d4cd78d9711..537952856401ce 100644 --- a/packages/app-store/_appRegistry.ts +++ b/packages/app-store/_appRegistry.ts @@ -1,6 +1,7 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { getAllDelegationCredentialsForUser } from "@calcom/app-store/delegationCredential"; import { getAppFromSlug } from "@calcom/app-store/utils"; +import { hasRequiredAppKeys } from "@calcom/app-store/_utils/hasRequiredAppKeys"; import type { UserAdminTeams } from "@calcom/features/users/repositories/UserRepository"; import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp"; import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma"; @@ -40,13 +41,20 @@ export async function getAppWithMetadata(app: { dirName: string } | { slug: stri export async function getAppRegistry() { const dbApps = await prisma.app.findMany({ where: { enabled: true }, - select: { dirName: true, slug: true, categories: true, enabled: true, createdAt: true }, + select: { dirName: true, slug: true, categories: true, enabled: true, createdAt: true, keys: true }, }); const apps = [] as App[]; const installCountPerApp = await getInstallCountPerApp(); for await (const dbapp of dbApps) { const app = await getAppWithMetadata(dbapp); if (!app) continue; + + // Skip apps that require keys but don't have them set + const hasKeys = await hasRequiredAppKeys(dbapp.dirName, dbapp.keys); + if (!hasKeys) { + continue; + } + // Skip if app isn't installed /* This is now handled from the DB */ // if (!app.installed) return apps; @@ -69,6 +77,7 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea where: { enabled: true }, select: { ...safeAppSelect, + keys: true, credentials: { where: { OR: [{ userId }, { teamId: { in: userAdminTeams } }] }, select: safeCredentialSelect, @@ -110,6 +119,13 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea const allCredentials = [...delegationCredentialsForApp, ...nonDelegationCredentialsForApp]; const app = await getAppWithMetadata(dbapp); if (!app) continue; + + // Skip apps that require keys but don't have them set + const hasKeys = await hasRequiredAppKeys(dbapp.dirName, dbapp.keys); + if (!hasKeys) { + continue; + } + // Skip if app isn't installed /* This is now handled from the DB */ // if (!app.installed) return apps; diff --git a/packages/app-store/_utils/hasRequiredAppKeys.test.ts b/packages/app-store/_utils/hasRequiredAppKeys.test.ts new file mode 100644 index 00000000000000..ddee1086e15b70 --- /dev/null +++ b/packages/app-store/_utils/hasRequiredAppKeys.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { hasRequiredAppKeys } from "./hasRequiredAppKeys"; + +describe("hasRequiredAppKeys", () => { + describe("Apps without key schemas", () => { + it("should return true for apps that don't require keys", async () => { + // Use a dirName that doesn't have a schema (e.g., a non-existent app) + const result = await hasRequiredAppKeys("non-existent-app", null); + expect(result).toBe(true); + }); + + it("should return true even if keys are null for apps without schemas", async () => { + const result = await hasRequiredAppKeys("non-existent-app", null); + expect(result).toBe(true); + }); + + it("should return true even if keys are empty object for apps without schemas", async () => { + const result = await hasRequiredAppKeys("non-existent-app", {}); + expect(result).toBe(true); + }); + }); + + describe("Apps with key schemas", () => { + it("should return false when keys are null", async () => { + // dailyvideo requires api_key and has scale_plan with default + const result = await hasRequiredAppKeys("dailyvideo", null); + expect(result).toBe(false); + }); + + it("should return false when keys are undefined", async () => { + const result = await hasRequiredAppKeys("dailyvideo", undefined as any); + expect(result).toBe(false); + }); + + it("should return false when keys is an empty object", async () => { + // Empty object doesn't have required api_key field + const result = await hasRequiredAppKeys("dailyvideo", {}); + expect(result).toBe(false); + }); + + it("should return false when keys is an array", async () => { + const result = await hasRequiredAppKeys("dailyvideo", [] as any); + expect(result).toBe(false); + }); + + it("should return false when keys is a string", async () => { + const result = await hasRequiredAppKeys("dailyvideo", "invalid" as any); + expect(result).toBe(false); + }); + + it("should return false when keys is a number", async () => { + const result = await hasRequiredAppKeys("dailyvideo", 123 as any); + expect(result).toBe(false); + }); + + it("should return false when required keys are missing", async () => { + // Missing required api_key field + const result = await hasRequiredAppKeys("dailyvideo", { + scale_plan: "true", + }); + expect(result).toBe(false); + }); + + it("should return false when required keys are empty strings", async () => { + // api_key is empty string, which violates .min(1) + const result = await hasRequiredAppKeys("dailyvideo", { + api_key: "", + scale_plan: "false", + }); + expect(result).toBe(false); + }); + + it("should return true when all required keys are present and valid", async () => { + const result = await hasRequiredAppKeys("dailyvideo", { + api_key: "valid-api-key", + scale_plan: "false", + }); + expect(result).toBe(true); + }); + + it("should return true when required keys are present and optional fields use defaults", async () => { + // scale_plan has a default, so we can omit it + const result = await hasRequiredAppKeys("dailyvideo", { + api_key: "valid-api-key", + }); + expect(result).toBe(true); + }); + + it("should return false when keys have wrong types", async () => { + // api_key should be string, not number + const result = await hasRequiredAppKeys("dailyvideo", { + api_key: 123 as any, + scale_plan: "false", + }); + expect(result).toBe(false); + }); + }); + + describe("Apps with multiple required fields", () => { + it("should return false when any required field is missing", async () => { + // vital requires mode, region, api_key, and webhook_secret + const result = await hasRequiredAppKeys("vital", { + mode: "sandbox", + region: "us", + api_key: "test-key", + // Missing webhook_secret + }); + expect(result).toBe(false); + }); + + it("should return true when all required fields are present", async () => { + const result = await hasRequiredAppKeys("vital", { + mode: "sandbox", + region: "us", + api_key: "test-key", + webhook_secret: "test-secret", + }); + expect(result).toBe(true); + }); + + it("should return false when any required field is empty", async () => { + const result = await hasRequiredAppKeys("vital", { + mode: "", + region: "us", + api_key: "test-key", + webhook_secret: "test-secret", + }); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/app-store/_utils/hasRequiredAppKeys.ts b/packages/app-store/_utils/hasRequiredAppKeys.ts new file mode 100644 index 00000000000000..2c1a21e34555b0 --- /dev/null +++ b/packages/app-store/_utils/hasRequiredAppKeys.ts @@ -0,0 +1,32 @@ +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; +import type { Prisma } from "@calcom/prisma/client"; +import type { z } from "zod"; + +/** + * Checks if an app has all required keys set and non-empty. + * Returns true if: + * - App has no key schema (no keys required) + * - App has a key schema and all required keys are present and non-empty + * Returns false if: + * - App has a key schema but keys are missing or empty + * + * @param dirName - The directory name of the app + * @param keys - The keys object from the database + */ +export async function hasRequiredAppKeys( + dirName: string, + keys: Prisma.JsonValue +): Promise { + const keySchema = appKeysSchemas[dirName as keyof typeof appKeysSchemas]; + + // If no schema, the app doesn't require keys + if (!keySchema) { + return true; + } + + // Validate keys against schema using safeParse + const result = keySchema.safeParse(keys); + + return result.success; +} From 9c2e58e582cd6ea3ea3ffc5980ef85c7ac3e3857 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Mon, 19 Jan 2026 23:33:11 +0530 Subject: [PATCH 08/12] update --- apps/web/app/api/cron/syncAppMeta/route.ts | 12 ++ packages/app-store/_appRegistry.ts | 18 +-- .../app-store/_utils/hasRequiredAppKeys.ts | 32 ----- .../app-store/_utils/validateAppKeys.test.ts | 132 ++++++++++++++++++ packages/app-store/_utils/validateAppKeys.ts | 22 +++ .../apps/repository/PrismaAppRepository.ts | 5 +- scripts/seed-app-store.ts | 6 +- 7 files changed, 175 insertions(+), 52 deletions(-) delete mode 100644 packages/app-store/_utils/hasRequiredAppKeys.ts create mode 100644 packages/app-store/_utils/validateAppKeys.test.ts create mode 100644 packages/app-store/_utils/validateAppKeys.ts diff --git a/apps/web/app/api/cron/syncAppMeta/route.ts b/apps/web/app/api/cron/syncAppMeta/route.ts index c5479da49536c7..927fe991bb9fd3 100644 --- a/apps/web/app/api/cron/syncAppMeta/route.ts +++ b/apps/web/app/api/cron/syncAppMeta/route.ts @@ -3,6 +3,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; +import { shouldEnableApp } from "@calcom/app-store/_utils/validateAppKeys"; import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; import type { AppCategories, Prisma } from "@calcom/prisma/client"; @@ -48,6 +49,17 @@ async function postHandler(request: NextRequest) { updates["dirName"] = app.dirName ?? app.slug; } + // Ensure app is only enabled if it has valid keys (or doesn't require keys) + const shouldBeEnabled = shouldEnableApp(dbApp.dirName, dbApp.keys); + if (dbApp.enabled !== shouldBeEnabled) { + updates["enabled"] = shouldBeEnabled; + if (!shouldBeEnabled && dbApp.enabled) { + log.warn( + `⚠️ Disabling app ${dbApp.slug} - required keys are missing or invalid. Please configure keys in admin settings.` + ); + } + } + if (Object.keys(updates).length > 0) { log.info(`🔨 Updating app ${dbApp.slug} with ${Object.keys(updates).join(", ")}`); if (!isDryRun) { diff --git a/packages/app-store/_appRegistry.ts b/packages/app-store/_appRegistry.ts index 537952856401ce..109d4cd78d9711 100644 --- a/packages/app-store/_appRegistry.ts +++ b/packages/app-store/_appRegistry.ts @@ -1,7 +1,6 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { getAllDelegationCredentialsForUser } from "@calcom/app-store/delegationCredential"; import { getAppFromSlug } from "@calcom/app-store/utils"; -import { hasRequiredAppKeys } from "@calcom/app-store/_utils/hasRequiredAppKeys"; import type { UserAdminTeams } from "@calcom/features/users/repositories/UserRepository"; import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp"; import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma"; @@ -41,20 +40,13 @@ export async function getAppWithMetadata(app: { dirName: string } | { slug: stri export async function getAppRegistry() { const dbApps = await prisma.app.findMany({ where: { enabled: true }, - select: { dirName: true, slug: true, categories: true, enabled: true, createdAt: true, keys: true }, + select: { dirName: true, slug: true, categories: true, enabled: true, createdAt: true }, }); const apps = [] as App[]; const installCountPerApp = await getInstallCountPerApp(); for await (const dbapp of dbApps) { const app = await getAppWithMetadata(dbapp); if (!app) continue; - - // Skip apps that require keys but don't have them set - const hasKeys = await hasRequiredAppKeys(dbapp.dirName, dbapp.keys); - if (!hasKeys) { - continue; - } - // Skip if app isn't installed /* This is now handled from the DB */ // if (!app.installed) return apps; @@ -77,7 +69,6 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea where: { enabled: true }, select: { ...safeAppSelect, - keys: true, credentials: { where: { OR: [{ userId }, { teamId: { in: userAdminTeams } }] }, select: safeCredentialSelect, @@ -119,13 +110,6 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea const allCredentials = [...delegationCredentialsForApp, ...nonDelegationCredentialsForApp]; const app = await getAppWithMetadata(dbapp); if (!app) continue; - - // Skip apps that require keys but don't have them set - const hasKeys = await hasRequiredAppKeys(dbapp.dirName, dbapp.keys); - if (!hasKeys) { - continue; - } - // Skip if app isn't installed /* This is now handled from the DB */ // if (!app.installed) return apps; diff --git a/packages/app-store/_utils/hasRequiredAppKeys.ts b/packages/app-store/_utils/hasRequiredAppKeys.ts deleted file mode 100644 index 2c1a21e34555b0..00000000000000 --- a/packages/app-store/_utils/hasRequiredAppKeys.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; -import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; -import type { Prisma } from "@calcom/prisma/client"; -import type { z } from "zod"; - -/** - * Checks if an app has all required keys set and non-empty. - * Returns true if: - * - App has no key schema (no keys required) - * - App has a key schema and all required keys are present and non-empty - * Returns false if: - * - App has a key schema but keys are missing or empty - * - * @param dirName - The directory name of the app - * @param keys - The keys object from the database - */ -export async function hasRequiredAppKeys( - dirName: string, - keys: Prisma.JsonValue -): Promise { - const keySchema = appKeysSchemas[dirName as keyof typeof appKeysSchemas]; - - // If no schema, the app doesn't require keys - if (!keySchema) { - return true; - } - - // Validate keys against schema using safeParse - const result = keySchema.safeParse(keys); - - return result.success; -} diff --git a/packages/app-store/_utils/validateAppKeys.test.ts b/packages/app-store/_utils/validateAppKeys.test.ts new file mode 100644 index 00000000000000..90b32df198bb70 --- /dev/null +++ b/packages/app-store/_utils/validateAppKeys.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { shouldEnableApp } from "./validateAppKeys"; + +describe("shouldEnableApp", () => { + describe("Apps without key schemas", () => { + it("should return true for apps that don't require keys", () => { + // Use a dirName that doesn't have a schema (e.g., a non-existent app) + const result = shouldEnableApp("non-existent-app", null); + expect(result).toBe(true); + }); + + it("should return true even if keys are null for apps without schemas", () => { + const result = shouldEnableApp("non-existent-app", null); + expect(result).toBe(true); + }); + + it("should return true even if keys are empty object for apps without schemas", () => { + const result = shouldEnableApp("non-existent-app", {}); + expect(result).toBe(true); + }); + }); + + describe("Apps with key schemas", () => { + it("should return false when keys are null", () => { + // dailyvideo requires api_key and has scale_plan with default + const result = shouldEnableApp("dailyvideo", null); + expect(result).toBe(false); + }); + + it("should return false when keys are undefined", () => { + const result = shouldEnableApp("dailyvideo", undefined); + expect(result).toBe(false); + }); + + it("should return false when keys is an empty object", () => { + // Empty object doesn't have required api_key field + const result = shouldEnableApp("dailyvideo", {}); + expect(result).toBe(false); + }); + + it("should return false when keys is an array", () => { + const result = shouldEnableApp("dailyvideo", [] as any); + expect(result).toBe(false); + }); + + it("should return false when keys is a string", () => { + const result = shouldEnableApp("dailyvideo", "invalid" as any); + expect(result).toBe(false); + }); + + it("should return false when keys is a number", () => { + const result = shouldEnableApp("dailyvideo", 123 as any); + expect(result).toBe(false); + }); + + it("should return false when required keys are missing", () => { + // Missing required api_key field + const result = shouldEnableApp("dailyvideo", { + scale_plan: "true", + }); + expect(result).toBe(false); + }); + + it("should return false when required keys are empty strings", () => { + // api_key is empty string, which violates .min(1) + const result = shouldEnableApp("dailyvideo", { + api_key: "", + scale_plan: "false", + }); + expect(result).toBe(false); + }); + + it("should return true when all required keys are present and valid", () => { + const result = shouldEnableApp("dailyvideo", { + api_key: "valid-api-key", + scale_plan: "false", + }); + expect(result).toBe(true); + }); + + it("should return true when required keys are present and optional fields use defaults", () => { + // scale_plan has a default, so we can omit it + const result = shouldEnableApp("dailyvideo", { + api_key: "valid-api-key", + }); + expect(result).toBe(true); + }); + + it("should return false when keys have wrong types", () => { + // api_key should be string, not number + const result = shouldEnableApp("dailyvideo", { + api_key: 123 as any, + scale_plan: "false", + }); + expect(result).toBe(false); + }); + }); + + describe("Apps with multiple required fields", () => { + it("should return false when any required field is missing", () => { + // vital requires mode, region, api_key, and webhook_secret + const result = shouldEnableApp("vital", { + mode: "sandbox", + region: "us", + api_key: "test-key", + // Missing webhook_secret + }); + expect(result).toBe(false); + }); + + it("should return true when all required fields are present", () => { + const result = shouldEnableApp("vital", { + mode: "sandbox", + region: "us", + api_key: "test-key", + webhook_secret: "test-secret", + }); + expect(result).toBe(true); + }); + + it("should return false when any required field is empty", () => { + const result = shouldEnableApp("vital", { + mode: "", + region: "us", + api_key: "test-key", + webhook_secret: "test-secret", + }); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/app-store/_utils/validateAppKeys.ts b/packages/app-store/_utils/validateAppKeys.ts new file mode 100644 index 00000000000000..2c68c042ab5228 --- /dev/null +++ b/packages/app-store/_utils/validateAppKeys.ts @@ -0,0 +1,22 @@ +import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; +import type { Prisma } from "@calcom/prisma/client"; +/** + * Determines if an app should be enabled based on whether it has valid keys. + * This is used by app registration scripts to prevent enabling apps without proper configuration. + * + * @param dirName - The directory name of the app + * @param keys - The keys object (can be undefined/null) + * @returns true if the app can be enabled, false otherwise + */ +export function shouldEnableApp(dirName: string, keys?: Prisma.JsonValue | null): boolean { + const keySchema = appKeysSchemas[dirName as keyof typeof appKeysSchemas]; + + // If no schema, the app doesn't require keys - can be enabled + if (!keySchema) { + return true; + } + + // Validate keys against schema + const result = keySchema.safeParse(keys); + return result.success; +} diff --git a/packages/features/apps/repository/PrismaAppRepository.ts b/packages/features/apps/repository/PrismaAppRepository.ts index 80caf3e2167e3c..79119e73856ff4 100644 --- a/packages/features/apps/repository/PrismaAppRepository.ts +++ b/packages/features/apps/repository/PrismaAppRepository.ts @@ -1,6 +1,7 @@ import { captureException } from "@sentry/nextjs"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { shouldEnableApp } from "@calcom/app-store/_utils/validateAppKeys"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -12,13 +13,15 @@ export class PrismaAppRepository { throw new Error(`App ${dirName} not found`); } + // Only enable if keys are valid (or app doesn't require keys) + const enabled = shouldEnableApp(dirName, keys as Prisma.JsonValue); await prisma.app.create({ data: { slug: appMetadata.slug, categories: appMetadata.categories, dirName: dirName, keys, - enabled: true, + enabled, }, }); } diff --git a/scripts/seed-app-store.ts b/scripts/seed-app-store.ts index e5dea33b22dc99..22e0725a641e69 100644 --- a/scripts/seed-app-store.ts +++ b/scripts/seed-app-store.ts @@ -6,6 +6,7 @@ import dotEnv from "dotenv"; import path from "node:path" import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { shouldEnableApp } from "@calcom/app-store/_utils/validateAppKeys"; import prisma from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { AppCategories } from "@calcom/prisma/enums"; @@ -48,8 +49,9 @@ async function createApp( }, }); - // We need to enable seeded apps as they are used in tests. - const data = { slug, dirName, categories, keys, enabled: true }; + // Only enable apps if they have valid keys (or don't require keys) + const enabled = shouldEnableApp(dirName, keys as Prisma.JsonValue); + const data = { slug, dirName, categories, keys, enabled }; if (!foundApp) { await prisma.app.create({ From fda90b092add78324e3b8a20f4e5bb5956a8aa86 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Mon, 19 Jan 2026 23:34:25 +0530 Subject: [PATCH 09/12] Delete packages/app-store/_utils/hasRequiredAppKeys.test.ts --- .../_utils/hasRequiredAppKeys.test.ts | 132 ------------------ 1 file changed, 132 deletions(-) delete mode 100644 packages/app-store/_utils/hasRequiredAppKeys.test.ts diff --git a/packages/app-store/_utils/hasRequiredAppKeys.test.ts b/packages/app-store/_utils/hasRequiredAppKeys.test.ts deleted file mode 100644 index ddee1086e15b70..00000000000000 --- a/packages/app-store/_utils/hasRequiredAppKeys.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { hasRequiredAppKeys } from "./hasRequiredAppKeys"; - -describe("hasRequiredAppKeys", () => { - describe("Apps without key schemas", () => { - it("should return true for apps that don't require keys", async () => { - // Use a dirName that doesn't have a schema (e.g., a non-existent app) - const result = await hasRequiredAppKeys("non-existent-app", null); - expect(result).toBe(true); - }); - - it("should return true even if keys are null for apps without schemas", async () => { - const result = await hasRequiredAppKeys("non-existent-app", null); - expect(result).toBe(true); - }); - - it("should return true even if keys are empty object for apps without schemas", async () => { - const result = await hasRequiredAppKeys("non-existent-app", {}); - expect(result).toBe(true); - }); - }); - - describe("Apps with key schemas", () => { - it("should return false when keys are null", async () => { - // dailyvideo requires api_key and has scale_plan with default - const result = await hasRequiredAppKeys("dailyvideo", null); - expect(result).toBe(false); - }); - - it("should return false when keys are undefined", async () => { - const result = await hasRequiredAppKeys("dailyvideo", undefined as any); - expect(result).toBe(false); - }); - - it("should return false when keys is an empty object", async () => { - // Empty object doesn't have required api_key field - const result = await hasRequiredAppKeys("dailyvideo", {}); - expect(result).toBe(false); - }); - - it("should return false when keys is an array", async () => { - const result = await hasRequiredAppKeys("dailyvideo", [] as any); - expect(result).toBe(false); - }); - - it("should return false when keys is a string", async () => { - const result = await hasRequiredAppKeys("dailyvideo", "invalid" as any); - expect(result).toBe(false); - }); - - it("should return false when keys is a number", async () => { - const result = await hasRequiredAppKeys("dailyvideo", 123 as any); - expect(result).toBe(false); - }); - - it("should return false when required keys are missing", async () => { - // Missing required api_key field - const result = await hasRequiredAppKeys("dailyvideo", { - scale_plan: "true", - }); - expect(result).toBe(false); - }); - - it("should return false when required keys are empty strings", async () => { - // api_key is empty string, which violates .min(1) - const result = await hasRequiredAppKeys("dailyvideo", { - api_key: "", - scale_plan: "false", - }); - expect(result).toBe(false); - }); - - it("should return true when all required keys are present and valid", async () => { - const result = await hasRequiredAppKeys("dailyvideo", { - api_key: "valid-api-key", - scale_plan: "false", - }); - expect(result).toBe(true); - }); - - it("should return true when required keys are present and optional fields use defaults", async () => { - // scale_plan has a default, so we can omit it - const result = await hasRequiredAppKeys("dailyvideo", { - api_key: "valid-api-key", - }); - expect(result).toBe(true); - }); - - it("should return false when keys have wrong types", async () => { - // api_key should be string, not number - const result = await hasRequiredAppKeys("dailyvideo", { - api_key: 123 as any, - scale_plan: "false", - }); - expect(result).toBe(false); - }); - }); - - describe("Apps with multiple required fields", () => { - it("should return false when any required field is missing", async () => { - // vital requires mode, region, api_key, and webhook_secret - const result = await hasRequiredAppKeys("vital", { - mode: "sandbox", - region: "us", - api_key: "test-key", - // Missing webhook_secret - }); - expect(result).toBe(false); - }); - - it("should return true when all required fields are present", async () => { - const result = await hasRequiredAppKeys("vital", { - mode: "sandbox", - region: "us", - api_key: "test-key", - webhook_secret: "test-secret", - }); - expect(result).toBe(true); - }); - - it("should return false when any required field is empty", async () => { - const result = await hasRequiredAppKeys("vital", { - mode: "", - region: "us", - api_key: "test-key", - webhook_secret: "test-secret", - }); - expect(result).toBe(false); - }); - }); -}); From fcf2aa1f9626ed09d8f4c4e62eebe6a5d03b6e56 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Mon, 19 Jan 2026 23:36:03 +0530 Subject: [PATCH 10/12] Update validateAppKeys.ts --- packages/app-store/_utils/validateAppKeys.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/app-store/_utils/validateAppKeys.ts b/packages/app-store/_utils/validateAppKeys.ts index 2c68c042ab5228..43160527fe3981 100644 --- a/packages/app-store/_utils/validateAppKeys.ts +++ b/packages/app-store/_utils/validateAppKeys.ts @@ -2,7 +2,6 @@ import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; import type { Prisma } from "@calcom/prisma/client"; /** * Determines if an app should be enabled based on whether it has valid keys. - * This is used by app registration scripts to prevent enabling apps without proper configuration. * * @param dirName - The directory name of the app * @param keys - The keys object (can be undefined/null) @@ -16,7 +15,6 @@ export function shouldEnableApp(dirName: string, keys?: Prisma.JsonValue | null) return true; } - // Validate keys against schema const result = keySchema.safeParse(keys); return result.success; } From 2814ae1e5a188a028a175172a50dc6a776739a2f Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:45:45 +0530 Subject: [PATCH 11/12] Update validateAppKeys.ts --- packages/app-store/_utils/validateAppKeys.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/app-store/_utils/validateAppKeys.ts b/packages/app-store/_utils/validateAppKeys.ts index 43160527fe3981..72369ea25fd3dc 100644 --- a/packages/app-store/_utils/validateAppKeys.ts +++ b/packages/app-store/_utils/validateAppKeys.ts @@ -2,10 +2,10 @@ import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; import type { Prisma } from "@calcom/prisma/client"; /** * Determines if an app should be enabled based on whether it has valid keys. + * This is used by app registration scripts to prevent enabling apps without proper configuration. * * @param dirName - The directory name of the app * @param keys - The keys object (can be undefined/null) - * @returns true if the app can be enabled, false otherwise */ export function shouldEnableApp(dirName: string, keys?: Prisma.JsonValue | null): boolean { const keySchema = appKeysSchemas[dirName as keyof typeof appKeysSchemas]; @@ -15,6 +15,14 @@ export function shouldEnableApp(dirName: string, keys?: Prisma.JsonValue | null) return true; } + // If keys is null/undefined, check if the schema accepts an empty object. + if (keys === null || keys === undefined) { + const emptyObjectResult = keySchema.safeParse({}); + return emptyObjectResult.success; + } + + + // Validate keys against schema const result = keySchema.safeParse(keys); return result.success; } From 1935b2ea3ca91fb9afaa863176e78ff26fe854b4 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:46:05 +0530 Subject: [PATCH 12/12] Update validateAppKeys.test.ts --- .../app-store/_utils/validateAppKeys.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/app-store/_utils/validateAppKeys.test.ts b/packages/app-store/_utils/validateAppKeys.test.ts index 90b32df198bb70..94957d2fb4be40 100644 --- a/packages/app-store/_utils/validateAppKeys.test.ts +++ b/packages/app-store/_utils/validateAppKeys.test.ts @@ -129,4 +129,39 @@ describe("shouldEnableApp", () => { expect(result).toBe(false); }); }); + + describe("Apps with empty key schemas (user-configured apps like PayPal, GTM)", () => { + // These apps have `appKeysSchema = z.object({})` - they don't need server-side keys + // Users configure them after installation, so they should always be enabled + + it("should return true for PayPal when keys are null", () => { + const result = shouldEnableApp("paypal", null); + expect(result).toBe(true); + }); + + it("should return true for PayPal when keys are undefined", () => { + const result = shouldEnableApp("paypal", undefined); + expect(result).toBe(true); + }); + + it("should return true for PayPal when keys are empty object", () => { + const result = shouldEnableApp("paypal", {}); + expect(result).toBe(true); + }); + + it("should return true for GTM when keys are null", () => { + const result = shouldEnableApp("gtm", null); + expect(result).toBe(true); + }); + + it("should return true for GTM when keys are undefined", () => { + const result = shouldEnableApp("gtm", undefined); + expect(result).toBe(true); + }); + + it("should return true for GA4 when keys are null", () => { + const result = shouldEnableApp("ga4", null); + expect(result).toBe(true); + }); + }); });