feat(webapp): Vercel integration (#2994)

Vercel integration

Desc + Vid coming soon


For human reviewer:
- check the db schema
- check if posthog user attribution call is correct (telemetry.server.ts
& `referralSource`)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2994"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
This commit is contained in:
Oskar Otwinowski
2026-02-10 10:37:09 +01:00
committed by GitHub
parent e536d35b17
commit 9b21f8d322
66 changed files with 8389 additions and 174 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add Vercel integration support to API schemas: `commitSHA` and `integrationDeployments` on deployment responses, and `source` field for environment variable imports.
+2 -1
View File
@@ -7,5 +7,6 @@
"packages/cli-v3/e2e": true
},
"vitest.disableWorkspaceWarning": true,
"typescript.experimental.useTsgo": false
"typescript.experimental.useTsgo": true,
"chat.agent.maxRequests": 10000
}
@@ -32,8 +32,6 @@ export function OctoKitty({ className }: { className?: string }) {
baseProfile="tiny"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 2350 2314.8"
xmlSpace="preserve"
fill="currentColor"
@@ -10,11 +10,14 @@ import { FormButtons } from "../primitives/FormButtons";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Paragraph } from "../primitives/Paragraph";
import { CheckboxWithLabel } from "../primitives/Checkbox";
import { Spinner } from "../primitives/Spinner";
type ModalProps = {
id: string;
title: string;
hasVercelIntegration: boolean;
isDevelopment: boolean;
};
type ModalContentProps = ModalProps & {
@@ -22,7 +25,12 @@ type ModalContentProps = ModalProps & {
closeModal: () => void;
};
export function RegenerateApiKeyModal({ id, title }: ModalProps) {
export function RegenerateApiKeyModal({
id,
title,
hasVercelIntegration,
isDevelopment,
}: ModalProps) {
const randomWord = generateTwoRandomWords();
const [open, setOpen] = useState(false);
return (
@@ -37,6 +45,8 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
<RegenerateApiKeyModalContent
id={id}
title={title}
hasVercelIntegration={hasVercelIntegration}
isDevelopment={isDevelopment}
randomWord={randomWord}
closeModal={() => setOpen(false)}
/>
@@ -45,7 +55,14 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
);
}
const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => {
const RegenerateApiKeyModalContent = ({
id,
randomWord,
title,
hasVercelIntegration,
isDevelopment,
closeModal,
}: ModalContentProps) => {
const [confirmationText, setConfirmationText] = useState("");
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
@@ -83,6 +100,15 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
onChange={(e) => setConfirmationText(e.target.value)}
/>
</InputGroup>
{hasVercelIntegration && !isDevelopment && (
<CheckboxWithLabel
name="syncToVercel"
variant="simple/small"
label="Also update TRIGGER_SECRET_KEY in Vercel"
defaultChecked={true}
value="on"
/>
)}
<FormButtons
confirmButton={
<Button
@@ -0,0 +1,176 @@
import { Switch } from "~/components/primitives/Switch";
import { Label } from "~/components/primitives/Label";
import { Hint } from "~/components/primitives/Hint";
import { TextLink } from "~/components/primitives/TextLink";
import {
EnvironmentIcon,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { envSlugToType, type EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema";
type BuildSettingsFieldsProps = {
availableEnvSlugs: EnvSlug[];
pullEnvVarsBeforeBuild: EnvSlug[];
onPullEnvVarsChange: (slugs: EnvSlug[]) => void;
discoverEnvVars: EnvSlug[];
onDiscoverEnvVarsChange: (slugs: EnvSlug[]) => void;
atomicBuilds: EnvSlug[];
onAtomicBuildsChange: (slugs: EnvSlug[]) => void;
envVarsConfigLink?: string;
};
export function BuildSettingsFields({
availableEnvSlugs,
pullEnvVarsBeforeBuild,
onPullEnvVarsChange,
discoverEnvVars,
onDiscoverEnvVarsChange,
atomicBuilds,
onAtomicBuildsChange,
envVarsConfigLink,
}: BuildSettingsFieldsProps) {
return (
<>
{/* Pull env vars before build */}
<div>
<div className="mb-2 flex items-center justify-between">
<div>
<Label>Pull env vars before build</Label>
<Hint>
Select which environments should pull environment variables from Vercel before each
build.{" "}
{envVarsConfigLink && (
<>
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
</>
)}
</Hint>
</div>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
}
onCheckedChange={(checked) => {
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
}}
/>
)}
</div>
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
return (
<div key={slug} className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
{environmentFullTitle({ type: envType })}
</span>
</div>
<Switch
variant="small"
checked={pullEnvVarsBeforeBuild.includes(slug)}
onCheckedChange={(checked) => {
onPullEnvVarsChange(
checked
? [...pullEnvVarsBeforeBuild, slug]
: pullEnvVarsBeforeBuild.filter((s) => s !== slug)
);
}}
/>
</div>
);
})}
</div>
</div>
{/* Discover new env vars */}
<div>
<div className="mb-2 flex items-center justify-between">
<div>
<Label>Discover new env vars</Label>
<Hint>
Select which environments should automatically discover and create new environment
variables from Vercel during builds.
</Hint>
</div>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every(
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
) &&
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
}
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
: []
);
}}
/>
)}
</div>
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
return (
<div
key={slug}
className={`flex items-center justify-between ${isPullDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
{environmentFullTitle({ type: envType })}
</span>
</div>
<Switch
variant="small"
checked={discoverEnvVars.includes(slug)}
disabled={isPullDisabled}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? [...discoverEnvVars, slug]
: discoverEnvVars.filter((s) => s !== slug)
);
}}
/>
</div>
);
})}
</div>
</div>
{/* Atomic deployments */}
<div>
<div className="flex items-center justify-between">
<div>
<Label>Atomic deployments</Label>
<Hint>
When enabled, production deployments wait for Vercel deployment to complete before
promoting the Trigger.dev deployment.
</Hint>
</div>
<Switch
variant="small"
checked={atomicBuilds.includes("prod")}
onCheckedChange={(checked) => {
onAtomicBuildsChange(checked ? ["prod"] : []);
}}
/>
</div>
</div>
</>
);
}
@@ -0,0 +1,12 @@
export function VercelLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 76 65"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
</svg>
);
}
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import {
ChartBarIcon,
Cog8ToothIcon,
CreditCardIcon,
PuzzlePieceIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
@@ -12,6 +13,7 @@ import { cn } from "~/utils/cn";
import {
organizationSettingsPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
v3BillingAlertsPath,
v3BillingPath,
@@ -113,6 +115,13 @@ export function OrganizationSettingsSideMenu({
to={organizationSettingsPath(organization)}
data-action="settings"
/>
<SideMenuItem
name="Integrations"
icon={PuzzlePieceIcon}
activeIconColor="text-blue-500"
to={organizationVercelIntegrationPath(organization)}
data-action="integrations"
/>
</div>
<div className="flex flex-col gap-1">
<SideMenuHeader title="App version" />
+5
View File
@@ -425,6 +425,11 @@ const EnvironmentSchema = z
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
/** Vercel integration OAuth credentials */
VERCEL_INTEGRATION_CLIENT_ID: z.string().optional(),
VERCEL_INTEGRATION_CLIENT_SECRET: z.string().optional(),
VERCEL_INTEGRATION_APP_SLUG: z.string().optional(),
/** These enable the alerts feature in v3 */
ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
ALERT_FROM_EMAIL: z.string().optional(),
@@ -47,6 +47,13 @@ export type AuthenticatableIntegration = OrganizationIntegration & {
tokenReference: SecretReference;
};
export function isIntegrationForService<TService extends IntegrationService>(
integration: AuthenticatableIntegration,
service: TService
): integration is OrganizationIntegrationForService<TService> {
return (integration.service satisfies IntegrationService) === service;
}
export class OrgIntegrationRepository {
static async getAuthenticatedClientForIntegration<TService extends IntegrationService>(
integration: OrganizationIntegrationForService<TService>,
@@ -89,6 +96,23 @@ export class OrgIntegrationRepository {
static isSlackSupported =
!!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET;
static isVercelSupported =
!!env.VERCEL_INTEGRATION_CLIENT_ID && !!env.VERCEL_INTEGRATION_CLIENT_SECRET && !!env.VERCEL_INTEGRATION_APP_SLUG;
/**
* Generate the URL to install the Vercel integration.
* Users are redirected to Vercel's marketplace to complete the installation.
*
* @param state - Base64-encoded state containing org/project info for the callback
*/
static vercelInstallUrl(state: string): string {
// The user goes to Vercel's marketplace to install the integration
// After installation, Vercel redirects to our callback with the authorization code
const redirectUri = encodeURIComponent(`${env.APP_ORIGIN}/vercel/callback`);
const encodedState = encodeURIComponent(state);
return `https://vercel.com/integrations/${env.VERCEL_INTEGRATION_APP_SLUG}/new?state=${encodedState}&redirect_uri=${redirectUri}`;
}
static slackAuthorizationUrl(
state: string,
scopes: string[] = [
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,11 @@ export class ApiKeysPresenter {
apiKey: true,
},
},
project: {
select: {
id: true,
},
},
},
where: {
project: {
@@ -64,11 +69,22 @@ export class ApiKeysPresenter {
throw new Error("Environment not found");
}
const vercelIntegration =
await this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId: environment.project.id,
deletedAt: null,
organizationIntegration: { service: "VERCEL", deletedAt: null },
},
select: { id: true },
});
return {
environment: {
...environment,
apiKey: environment?.parentEnvironment?.apiKey ?? environment?.apiKey,
},
hasVercelIntegration: vercelIntegration !== null,
};
}
}
@@ -1,5 +1,5 @@
import {
type Prisma,
Prisma,
type WorkerDeploymentStatus,
type WorkerInstanceGroupType,
} from "@trigger.dev/database";
@@ -10,6 +10,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { processGitMetadata } from "./BranchesPresenter.server";
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
const pageSize = 20;
@@ -105,6 +106,51 @@ export class DeploymentListPresenter {
},
});
// Check for Vercel integration before the main query so we can conditionally LEFT JOIN
let hasVercelIntegration = false;
let vercelTeamSlug: string | undefined;
let vercelProjectName: string | undefined;
const vercelProjectIntegration =
await this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId: project.id,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
select: {
integrationData: true,
},
});
if (vercelProjectIntegration) {
const parsed = VercelProjectIntegrationDataSchema.safeParse(
vercelProjectIntegration.integrationData
);
if (parsed.success && parsed.data.vercelTeamSlug) {
hasVercelIntegration = true;
vercelTeamSlug = parsed.data.vercelTeamSlug;
vercelProjectName = parsed.data.vercelProjectName;
}
}
const vercelSelect = hasVercelIntegration
? Prisma.sql`, id_dep."integrationDeploymentId"`
: Prisma.sql``;
const vercelJoin = hasVercelIntegration
? Prisma.sql`LEFT JOIN LATERAL (
SELECT id_inner."integrationDeploymentId"
FROM ${sqlDatabaseSchema}."IntegrationDeployment" as id_inner
WHERE id_inner."deploymentId" = wd."id" AND id_inner."integrationName" = 'vercel'
ORDER BY id_inner."createdAt" DESC
LIMIT 1
) id_dep ON true`
: Prisma.sql``;
const deployments = await this.#prismaClient.$queryRaw<
{
id: string;
@@ -123,6 +169,7 @@ export class DeploymentListPresenter {
userAvatarUrl: string | null;
type: WorkerInstanceGroupType;
git: Prisma.JsonValue | null;
integrationDeploymentId: string | null;
}[]
>`
SELECT
@@ -142,10 +189,12 @@ export class DeploymentListPresenter {
wd."deployedAt",
wd."type",
wd."git"
${vercelSelect}
FROM
${sqlDatabaseSchema}."WorkerDeployment" as wd
LEFT JOIN
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
${vercelJoin}
WHERE
wd."projectId" = ${project.id}
AND wd."environmentId" = ${environment.id}
@@ -173,6 +222,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
hasVercelIntegration,
connectedGithubRepository: project.connectedGithubRepository ?? undefined,
environmentGitHubBranch,
deployments: deployments.map((deployment, index) => {
@@ -180,6 +230,12 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
);
let vercelDeploymentUrl: string | null = null;
if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) {
const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, "");
vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
}
return {
id: deployment.id,
shortCode: deployment.shortCode,
@@ -210,6 +266,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
}
: undefined,
git: processGitMetadata(deployment.git),
vercelDeploymentUrl,
};
}),
};
@@ -1,9 +1,14 @@
import { flipCauseOption } from "effect/Cause";
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import type { EnvironmentVariableUpdater } from "~/v3/environmentVariables/repository";
import {
SyncEnvVarsMapping,
EnvSlug,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number];
@@ -44,6 +49,9 @@ export class EnvironmentVariablesPresenter {
select: {
id: true,
environmentId: true,
version: true,
lastUpdatedBy: true,
updatedAt: true,
valueReference: {
select: {
key: true,
@@ -67,6 +75,42 @@ export class EnvironmentVariablesPresenter {
},
});
const userIds = new Set(
environmentVariables
.flatMap((envVar) => envVar.values)
.map((value) => value.lastUpdatedBy)
.filter(
(lastUpdatedBy): lastUpdatedBy is { type: "user"; userId: string } =>
lastUpdatedBy !== null &&
typeof lastUpdatedBy === "object" &&
"type" in lastUpdatedBy &&
lastUpdatedBy.type === "user" &&
"userId" in lastUpdatedBy &&
typeof lastUpdatedBy.userId === "string"
)
.map((lastUpdatedBy) => lastUpdatedBy.userId)
);
const users =
userIds.size > 0
? await this.#prismaClient.user.findMany({
where: {
id: {
in: Array.from(userIds),
},
},
select: {
id: true,
name: true,
displayName: true,
avatarUrl: true,
},
})
: [];
const usersRecord: Record<string, { id: string; name: string | null; displayName: string | null; avatarUrl: string | null }> =
Object.fromEntries(users.map((u) => [u.id, u]));
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
select: {
id: true,
@@ -94,6 +138,18 @@ export class EnvironmentVariablesPresenter {
const repository = new EnvironmentVariablesRepository(this.#prismaClient);
const variables = await repository.getProject(project.id);
// Get Vercel integration data if it exists
const vercelService = new VercelIntegrationService(this.#prismaClient);
const vercelIntegration = await vercelService.getVercelProjectIntegration(project.id);
let vercelSyncEnvVarsMapping: SyncEnvVarsMapping = {};
let vercelPullEnvVarsBeforeBuild: EnvSlug[] | null = null;
if (vercelIntegration) {
vercelSyncEnvVarsMapping = vercelIntegration.parsedIntegrationData.syncEnvVarsMapping;
vercelPullEnvVarsBeforeBuild = vercelIntegration.parsedIntegrationData.config.pullEnvVarsBeforeBuild ?? null;
}
return {
environmentVariables: environmentVariables
.flatMap((environmentVariable) => {
@@ -101,13 +157,29 @@ export class EnvironmentVariablesPresenter {
return sortedEnvironments.flatMap((env) => {
const val = variable?.values.find((v) => v.environment.id === env.id);
const isSecret =
environmentVariable.values.find((v) => v.environmentId === env.id)?.isSecret ?? false;
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
const isSecret = valueRecord?.isSecret ?? false;
if (!val) {
if (!val || !valueRecord) {
return [];
}
const lastUpdatedBy = valueRecord.lastUpdatedBy as EnvironmentVariableUpdater | null;
const updatedByUser =
lastUpdatedBy?.type === "user"
? (() => {
const user = usersRecord[lastUpdatedBy.userId];
return user
? {
id: user.id,
name: user.displayName || user.name || "Unknown",
avatarUrl: user.avatarUrl,
}
: null;
})()
: null;
return [
{
id: environmentVariable.id,
@@ -115,6 +187,10 @@ export class EnvironmentVariablesPresenter {
environment: { type: env.type, id: env.id, branchName: env.branchName },
value: isSecret ? "" : val.value,
isSecret,
version: valueRecord.version,
lastUpdatedBy,
updatedByUser,
updatedAt: valueRecord.updatedAt,
},
];
});
@@ -127,6 +203,14 @@ export class EnvironmentVariablesPresenter {
branchName: environment.branchName,
})),
hasStaging: environments.some((environment) => environment.type === "STAGING"),
// Vercel integration data
vercelIntegration: vercelIntegration
? {
enabled: true,
pullEnvVarsBeforeBuild: vercelPullEnvVarsBeforeBuild,
syncEnvVarsMapping: vercelSyncEnvVarsMapping,
}
: null,
};
}
}
@@ -0,0 +1,585 @@
import { type PrismaClient } from "@trigger.dev/database";
import { type Result, fromPromise, ok, okAsync, ResultAsync } from "neverthrow";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import {
VercelIntegrationRepository,
VercelCustomEnvironment,
VercelEnvironmentVariable,
} from "~/models/vercelIntegration.server";
import { type GitHubAppInstallation } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import {
VercelProjectIntegrationDataSchema,
VercelProjectIntegrationData,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { BasePresenter } from "./basePresenter.server";
type VercelSettingsOptions = {
projectId: string;
organizationId: string;
};
export type VercelSettingsResult = {
enabled: boolean;
hasOrgIntegration: boolean;
authInvalid?: boolean;
connectedProject?: {
id: string;
vercelProjectId: string;
vercelProjectName: string;
vercelTeamId: string | null;
integrationData: VercelProjectIntegrationData;
createdAt: Date;
};
isGitHubConnected: boolean;
hasStagingEnvironment: boolean;
hasPreviewEnvironment: boolean;
customEnvironments: VercelCustomEnvironment[];
/** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */
autoAssignCustomDomains?: boolean | null;
};
export type VercelAvailableProject = {
id: string;
name: string;
};
export type VercelOnboardingData = {
customEnvironments: VercelCustomEnvironment[];
environmentVariables: VercelEnvironmentVariable[];
availableProjects: VercelAvailableProject[];
hasProjectSelected: boolean;
authInvalid?: boolean;
existingVariables: Record<string, { environments: string[] }>; // Environment slugs (non-archived only)
gitHubAppInstallations: GitHubAppInstallation[];
isGitHubConnected: boolean;
isOnboardingComplete: boolean;
};
export class VercelSettingsPresenter extends BasePresenter {
/**
* Get Vercel integration settings for the settings page
*/
public async call({ projectId, organizationId }: VercelSettingsOptions): Promise<Result<VercelSettingsResult, unknown>> {
const vercelIntegrationEnabled = OrgIntegrationRepository.isVercelSupported;
if (!vercelIntegrationEnabled) {
return ok({
enabled: false,
hasOrgIntegration: false,
authInvalid: false,
connectedProject: undefined,
isGitHubConnected: false,
hasStagingEnvironment: false,
hasPreviewEnvironment: false,
customEnvironments: [],
} as VercelSettingsResult);
}
const orgIntegrationResult = await fromPromise(
(this._replica as PrismaClient).organizationIntegration.findFirst({
where: {
organizationId,
service: "VERCEL",
deletedAt: null,
},
include: {
tokenReference: true,
},
}),
(error) => error
);
if (orgIntegrationResult.isErr()) {
logger.error("Unexpected error in VercelSettingsPresenter.call", { error: orgIntegrationResult.error });
return ok({
enabled: true,
hasOrgIntegration: false,
authInvalid: true,
connectedProject: undefined,
isGitHubConnected: false,
hasStagingEnvironment: false,
hasPreviewEnvironment: false,
customEnvironments: [],
} as VercelSettingsResult);
}
const orgIntegration = orgIntegrationResult.value;
const hasOrgIntegration = orgIntegration !== null;
if (hasOrgIntegration) {
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
if (tokenResult.isErr() || !tokenResult.value.isValid) {
return ok({
enabled: true,
hasOrgIntegration: true,
authInvalid: true,
connectedProject: undefined,
isGitHubConnected: false,
hasStagingEnvironment: false,
hasPreviewEnvironment: false,
customEnvironments: [],
} as VercelSettingsResult);
}
}
const checkOrgIntegration = () => fromPromise(
Promise.resolve(hasOrgIntegration),
(error) => ({
type: "other" as const,
cause: error,
})
);
const checkGitHubConnection = () =>
fromPromise(
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
id: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((repo) => repo !== null);
const checkStagingEnvironment = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId,
type: "STAGING",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((env) => env !== null);
const checkPreviewEnvironment = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId,
type: "PREVIEW",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((env) => env !== null);
const getVercelProjectIntegration = () =>
fromPromise(
(this._replica as PrismaClient).organizationProjectIntegration.findFirst({
where: {
projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
include: {
organizationIntegration: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((integration) => {
if (!integration) {
return undefined;
}
const parsedData = VercelProjectIntegrationDataSchema.safeParse(
integration.integrationData
);
if (!parsedData.success) {
return undefined;
}
return {
id: integration.id,
vercelProjectId: integration.externalEntityId,
vercelProjectName: parsedData.data.vercelProjectName,
vercelTeamId: parsedData.data.vercelTeamId,
integrationData: parsedData.data,
createdAt: integration.createdAt,
};
});
return ResultAsync.combine([
checkOrgIntegration(),
checkGitHubConnection(),
checkStagingEnvironment(),
checkPreviewEnvironment(),
getVercelProjectIntegration(),
]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => {
const fetchCustomEnvsAndProjectSettings = async (): Promise<{
customEnvironments: VercelCustomEnvironment[];
autoAssignCustomDomains: boolean | null;
}> => {
if (!connectedProject || !orgIntegration) {
return { customEnvironments: [], autoAssignCustomDomains: null };
}
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
if (clientResult.isErr()) {
return { customEnvironments: [], autoAssignCustomDomains: null };
}
const client = clientResult.value;
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
const [customEnvsResult, autoAssignResult] = await Promise.all([
VercelIntegrationRepository.getVercelCustomEnvironments(
client,
connectedProject.vercelProjectId,
teamId
),
VercelIntegrationRepository.getAutoAssignCustomDomains(
client,
connectedProject.vercelProjectId,
teamId
),
]);
return {
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
};
};
return fromPromise(
fetchCustomEnvsAndProjectSettings(),
(error) => ({ type: "other" as const, cause: error })
).map(({ customEnvironments, autoAssignCustomDomains }) => ({
enabled: true,
hasOrgIntegration,
authInvalid: false,
connectedProject,
isGitHubConnected,
hasStagingEnvironment,
hasPreviewEnvironment,
customEnvironments,
autoAssignCustomDomains,
} as VercelSettingsResult));
}).mapErr((error) => {
// Log the error and return a safe fallback
logger.error("Error in VercelSettingsPresenter.call", { error });
return error;
});
}
/**
* Get data needed for the onboarding modal (custom environments and env vars)
*/
public async getOnboardingData(
projectId: string,
organizationId: string,
vercelEnvironmentId?: string
): Promise<VercelOnboardingData | null> {
const result = await ResultAsync.fromPromise(
(async (): Promise<VercelOnboardingData | null> => {
const [gitHubInstallations, connectedGitHubRepo] = await Promise.all([
(this._replica as PrismaClient).githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
id: true,
},
}),
]);
const isGitHubConnected = connectedGitHubRepo !== null;
const gitHubAppInstallations: GitHubAppInstallation[] = gitHubInstallations.map((installation) => ({
id: installation.id,
appInstallationId: installation.appInstallationId,
targetType: installation.targetType,
accountHandle: installation.accountHandle,
repositories: installation.repositories.map((repo) => ({
id: repo.id,
name: repo.name,
fullName: repo.fullName,
private: repo.private,
htmlUrl: repo.htmlUrl,
})),
}));
const orgIntegration = await (this._replica as PrismaClient).organizationIntegration.findFirst({
where: {
organizationId,
service: "VERCEL",
deletedAt: null,
},
include: {
tokenReference: true,
},
});
if (!orgIntegration) {
return null;
}
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
if (tokenResult.isErr() || !tokenResult.value.isValid) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: [],
hasProjectSelected: false,
authInvalid: true,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
if (clientResult.isErr()) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: [],
hasProjectSelected: false,
authInvalid: clientResult.error.authInvalid,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const client = clientResult.value;
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
const projectIntegration = await (this._replica as PrismaClient).organizationProjectIntegration.findFirst({
where: {
projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
});
const availableProjectsResult = await VercelIntegrationRepository.getVercelProjects(client, teamId);
if (availableProjectsResult.isErr()) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: [],
hasProjectSelected: false,
authInvalid: availableProjectsResult.error.authInvalid,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
if (!projectIntegration) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: availableProjectsResult.value,
hasProjectSelected: false,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const [customEnvironmentsResult, projectEnvVarsResult, sharedEnvVarsResult] = await Promise.all([
VercelIntegrationRepository.getVercelCustomEnvironments(
client,
projectIntegration.externalEntityId,
teamId
),
VercelIntegrationRepository.getVercelEnvironmentVariables(
client,
projectIntegration.externalEntityId,
teamId
),
// Only fetch shared env vars if teamId is available
teamId
? VercelIntegrationRepository.getVercelSharedEnvironmentVariables(
client,
teamId,
projectIntegration.externalEntityId
)
: okAsync([] as Array<{ id: string; key: string; type: string; isSecret: boolean; target: string[] }>),
]);
const authInvalid =
(customEnvironmentsResult.isErr() && customEnvironmentsResult.error.authInvalid) ||
(projectEnvVarsResult.isErr() && projectEnvVarsResult.error.authInvalid) ||
(sharedEnvVarsResult.isErr() && sharedEnvVarsResult.error.authInvalid);
if (authInvalid) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: availableProjectsResult.value,
hasProjectSelected: true,
authInvalid: true,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const customEnvironments = customEnvironmentsResult.isOk() ? customEnvironmentsResult.value : [];
const projectEnvVars = projectEnvVarsResult.isOk() ? projectEnvVarsResult.value : [];
const sharedEnvVars = sharedEnvVarsResult.isOk() ? sharedEnvVarsResult.value : [];
// Filter out TRIGGER_SECRET_KEY and TRIGGER_VERSION (managed by Trigger.dev) and merge project + shared env vars
const excludedKeys = new Set(["TRIGGER_SECRET_KEY", "TRIGGER_VERSION"]);
const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key));
const mergedEnvVars: VercelEnvironmentVariable[] = [
...projectEnvVars
.filter((v) => !excludedKeys.has(v.key))
.map((v) => {
const envVar = { ...v };
if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) {
envVar.target = [...v.target, 'staging'];
}
return envVar;
}),
...sharedEnvVars
.filter((v) => !projectEnvVarKeys.has(v.key) && !excludedKeys.has(v.key))
.map((v) => {
const envVar = {
id: v.id,
key: v.key,
type: v.type as VercelEnvironmentVariable["type"],
isSecret: v.isSecret,
target: v.target,
isShared: true,
customEnvironmentIds: [] as string[],
};
if (vercelEnvironmentId && (v as any).customEnvironmentIds?.includes(vercelEnvironmentId)) {
envVar.target = [...v.target, 'staging'];
}
return envVar;
}),
];
const sortedEnvVars = [...mergedEnvVars].sort((a, b) =>
a.key.localeCompare(b.key)
);
const projectEnvs = await (this._replica as PrismaClient).runtimeEnvironment.findMany({
where: {
projectId,
archivedAt: null, // Filter out archived environments
},
select: {
id: true,
slug: true,
type: true,
},
});
const envIdToSlug = new Map(projectEnvs.map((e) => [e.id, e.slug]));
const activeEnvIds = new Set(projectEnvs.map((e) => e.id));
const envVarRepository = new EnvironmentVariablesRepository(this._replica as PrismaClient);
const existingVariables = await envVarRepository.getProject(projectId);
const existingVariablesRecord: Record<string, { environments: string[] }> = {};
for (const v of existingVariables) {
// Filter out archived environments and map to slugs
const activeEnvSlugs = v.values
.filter((val) => activeEnvIds.has(val.environment.id))
.map((val) => envIdToSlug.get(val.environment.id) || val.environment.type.toLowerCase());
if (activeEnvSlugs.length > 0) {
existingVariablesRecord[v.key] = {
environments: activeEnvSlugs,
};
}
}
const parsedIntegrationData = VercelProjectIntegrationDataSchema.safeParse(
projectIntegration.integrationData
);
return {
customEnvironments,
environmentVariables: sortedEnvVars,
availableProjects: availableProjectsResult.value,
hasProjectSelected: true,
existingVariables: existingVariablesRecord,
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: parsedIntegrationData.success
? (parsedIntegrationData.data.onboardingCompleted ?? false)
: false,
};
})(),
(error) => error
);
if (result.isErr()) {
logger.error("Error in getOnboardingData", { error: result.error });
return null;
}
return result.value;
}
}
@@ -51,7 +51,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
try {
const presenter = new ApiKeysPresenter();
const { environment } = await presenter.call({
const { environment, hasVercelIntegration } = await presenter.call({
userId,
projectSlug: projectParam,
environmentSlug: envParam,
@@ -59,6 +59,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
environment,
hasVercelIntegration,
});
} catch (error) {
console.error(error);
@@ -70,7 +71,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function Page() {
const { environment } = useTypedLoaderData<typeof loader>();
const { environment, hasVercelIntegration } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
if (!environment) {
@@ -132,6 +133,8 @@ export default function Page() {
<RegenerateApiKeyModal
id={environment.parentEnvironment?.id ?? environment.id}
title={environmentFullTitle(environment)}
hasVercelIntegration={hasVercelIntegration}
isDevelopment={environment.type === "DEVELOPMENT"}
/>
</div>
<ClipboardField
@@ -19,6 +19,7 @@ import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { GitMetadata } from "~/components/GitMetadata";
@@ -55,6 +56,7 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import {
DeploymentStatus,
deploymentStatusDescription,
@@ -160,6 +162,7 @@ export default function Page() {
connectedGithubRepository,
environmentGitHubBranch,
autoReloadPollIntervalMs,
hasVercelIntegration,
} = useTypedLoaderData<typeof loader>();
const hasDeployments = totalPages > 0;
@@ -234,6 +237,7 @@ export default function Page() {
<TableHeaderCell>Deployed at</TableHeaderCell>
<TableHeaderCell>Deployed by</TableHeaderCell>
<TableHeaderCell>Git</TableHeaderCell>
{hasVercelIntegration && <TableHeaderCell>Linked</TableHeaderCell>}
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
@@ -307,6 +311,28 @@ export default function Page() {
<GitMetadata git={deployment.git} />
</div>
</TableCell>
{hasVercelIntegration && (
<TableCell isSelected={isSelected}>
{deployment.vercelDeploymentUrl ? (
<SimpleTooltip
button={
<a
href={deployment.vercelDeploymentUrl}
target="_blank"
rel="noreferrer noopener"
className="flex items-center text-text-dimmed transition-colors hover:text-text-bright"
onClick={(e) => e.stopPropagation()}
>
<VercelLogo className="size-3.5" />
</a>
}
content="View on Vercel"
/>
) : (
""
)}
</TableCell>
)}
<DeploymentActionsCell
deployment={deployment}
path={path}
@@ -317,7 +343,7 @@ export default function Page() {
);
})
) : (
<TableBlankRow colSpan={8}>
<TableBlankRow colSpan={hasVercelIntegration ? 9 : 8}>
<Paragraph className="flex items-center justify-center">
No deploys match your filters
</Paragraph>
@@ -151,7 +151,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.create(project.id, submission.value);
const result = await repository.create(project.id, {
...submission.value,
lastUpdatedBy: {
type: "user",
userId,
},
});
if (!result.success) {
if (result.variableErrors) {
@@ -9,7 +9,7 @@ import {
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation } from "@remix-run/react";
import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation, useRevalidator } from "@remix-run/react";
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
@@ -19,10 +19,12 @@ import { useEffect, useMemo, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
@@ -70,6 +72,11 @@ import {
EditEnvironmentVariableValue,
EnvironmentVariable,
} from "~/v3/environmentVariables/repository";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
import { fromPromise } from "neverthrow";
import { logger } from "~/services/logger.server";
import { shouldSyncEnvVar, isPullEnvVarsEnabledForEnvironment, type TriggerEnvironmentType } from "~/v3/vercel/vercelProjectIntegrationSchema";
export const meta: MetaFunction = () => {
return [
@@ -85,7 +92,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
try {
const presenter = new EnvironmentVariablesPresenter();
const { environmentVariables, environments, hasStaging } = await presenter.call({
const { environmentVariables, environments, hasStaging, vercelIntegration } = await presenter.call({
userId,
projectSlug: projectParam,
});
@@ -94,6 +101,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
environmentVariables,
environments,
hasStaging,
vercelIntegration,
});
} catch (error) {
console.error(error);
@@ -111,6 +119,12 @@ const schema = z.discriminatedUnion("action", [
key: z.string(),
...DeleteEnvironmentVariableValue.shape,
}),
z.object({
action: z.literal("update-vercel-sync"),
key: z.string(),
environmentType: z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]),
syncEnabled: z.union([z.literal("true"), z.literal("false")]).transform((val) => val === "true"),
}),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
@@ -151,7 +165,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
switch (submission.value.action) {
case "edit": {
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.editValue(project.id, submission.value);
const result = await repository.editValue(project.id, {
...submission.value,
lastUpdatedBy: {
type: "user",
userId,
},
});
if (!result.success) {
submission.error.key = [result.error];
@@ -169,6 +189,32 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
return json(submission);
}
// Clean up syncEnvVarsMapping if Vercel integration exists (best-effort)
const { environmentId, key } = submission.value;
const vercelService = new VercelIntegrationService();
await fromPromise(
(async () => {
const integration = await vercelService.getVercelProjectIntegration(project.id);
if (integration) {
const runtimeEnv = await prisma.runtimeEnvironment.findUnique({
where: { id: environmentId },
select: { type: true },
});
if (runtimeEnv) {
await vercelService.removeSyncEnvVarForEnvironment(
project.id,
key,
runtimeEnv.type as TriggerEnvironmentType
);
}
}
})(),
(error) => error
).mapErr((error) => {
logger.error("Failed to remove Vercel sync mapping", { error });
return error;
});
return redirectWithSuccessMessage(
v3EnvironmentVariablesPath(
{ slug: organizationSlug },
@@ -179,12 +225,31 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
`Deleted ${submission.value.key} environment variable`
);
}
case "update-vercel-sync": {
const vercelService = new VercelIntegrationService();
const integration = await vercelService.getVercelProjectIntegration(project.id);
if (!integration) {
submission.error.key = ["Vercel integration not found"];
return json(submission);
}
// Update the sync mapping for the specific env var and environment
await vercelService.updateSyncEnvVarForEnvironment(
project.id,
submission.value.key,
submission.value.environmentType,
submission.value.syncEnabled
);
return json({ success: true });
}
}
};
export default function Page() {
const [revealAll, setRevealAll] = useState(false);
const { environmentVariables, environments } = useTypedLoaderData<typeof loader>();
const { environmentVariables, environments, vercelIntegration } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -279,10 +344,32 @@ export default function Page() {
<Table containerClassName={cn(filteredItems.length === 0 && "border-t-0")}>
<TableHeader>
<TableRow>
<TableHeaderCell className="w-[25%]">Key</TableHeaderCell>
<TableHeaderCell className="w-[55%]">Value</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Environment</TableHeaderCell>
<TableHeaderCell hiddenLabel className="pl-24">
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[22%]" : "w-[25%]"}>
Key
</TableHeaderCell>
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[32%]" : "w-[37%]"}>
Value
</TableHeaderCell>
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[13%]" : "w-[15%]"}>
Environment
</TableHeaderCell>
{vercelIntegration?.enabled && (
<TableHeaderCell className="w-[8%]">
<SimpleTooltip
button={
<span className="flex items-center gap-1">
Sync
<InformationCircleIcon className="size-4 text-text-dimmed" />
</span>
}
content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings."
/>
</TableHeaderCell>
)}
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[24%]" : "w-[22%]"}>
Updated
</TableHeaderCell>
<TableHeaderCell hiddenLabel className="w-0">
Actions
</TableHeaderCell>
</TableRow>
@@ -341,9 +428,54 @@ export default function Page() {
<TableCell className={cn(cellClassName, borderedCellClassName)}>
<EnvironmentCombo environment={variable.environment} className="text-sm" />
</TableCell>
{vercelIntegration?.enabled && (
<TableCell className={cn(cellClassName, borderedCellClassName)}>
{variable.environment.type !== "DEVELOPMENT" && (
<VercelSyncCheckbox
envVarKey={variable.key}
environmentType={variable.environment.type as TriggerEnvironmentType}
syncEnabled={shouldSyncEnvVar(
vercelIntegration.syncEnvVarsMapping,
variable.key,
variable.environment.type as TriggerEnvironmentType
)}
pullEnvVarsEnabledForEnv={isPullEnvVarsEnabledForEnvironment(
vercelIntegration.pullEnvVarsBeforeBuild,
variable.environment.type as TriggerEnvironmentType
)}
/>
)}
</TableCell>
)}
<TableCell className={cn(cellClassName, borderedCellClassName)}>
<div className="flex items-center gap-3">
{variable.updatedByUser ? (
<div className="flex items-center gap-2">
<UserAvatar
avatarUrl={variable.updatedByUser.avatarUrl}
name={variable.updatedByUser.name}
className="size-5"
/>
<span className="text-sm">{variable.updatedByUser.name}</span>
</div>
) : (variable.lastUpdatedBy?.type === "integration" && variable.lastUpdatedBy?.integration === 'vercel' ) ? (
<div className="flex items-center gap-2">
<VercelLogo className="size-4 text-text-dimmed group-hover/table-row:text-text-bright transition-colors" />
<span className="text-sm text-text-dimmed group-hover/table-row:text-text-bright capitalize transition-colors">
{variable.lastUpdatedBy.integration}
</span>
</div>
) : null}
{variable.updatedAt ? (
<span className="text-sm text-text-dimmed">
<DateTime date={variable.updatedAt} includeSeconds={false} />
</span>
) : null}
</div>
</TableCell>
<TableCellMenu
className={cn(cellClassName, borderedCellClassName)}
isSticky
className="w-0 [&:has(.group-hover/table-row:block)]:w-auto"
hiddenButtons={
<>
<EditEnvironmentVariablePanel
@@ -359,7 +491,7 @@ export default function Page() {
})
) : (
<TableRow>
<TableCell colSpan={4}>
<TableCell colSpan={vercelIntegration?.enabled ? 6 : 5}>
{environmentVariables.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
<Header2>You haven't set any environment variables yet.</Header2>
@@ -430,7 +562,7 @@ function EditEnvironmentVariablePanel({
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={PencilSquareIcon} fullWidth textAlignLeft>
Edit
</Button>
</DialogTrigger>
<DialogContent>
@@ -526,8 +658,82 @@ function DeleteEnvironmentVariableButton({
leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors"
className="ml-0.5 transition-colors group-hover/button:bg-error"
>
{isLoading ? "Deleting" : "Delete"}
{isLoading ? "Deleting" : ""}
</Button>
</Form>
);
}
/**
* Toggle component for controlling whether an environment variable is pulled from Vercel.
*
* When enabled, the variable will be pulled from Vercel during builds.
* By default, all variables are pulled unless explicitly disabled.
*
* Note: If the env slug is missing from syncEnvVarsMapping, all vars are pulled by default.
* Only when syncEnvVarsMapping[envSlug][envVarName] = false, the env var is skipped during builds.
*/
function VercelSyncCheckbox({
envVarKey,
environmentType,
syncEnabled,
pullEnvVarsEnabledForEnv,
}: {
envVarKey: string;
environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT";
syncEnabled: boolean;
pullEnvVarsEnabledForEnv: boolean;
}) {
const fetcher = useFetcher();
const revalidator = useRevalidator();
const isLoading = fetcher.state !== "idle";
// Revalidate loader data after successful submission (without full page reload)
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
const data = fetcher.data as { success?: boolean };
if (data.success) {
revalidator.revalidate();
}
}
}, [fetcher.state, fetcher.data, revalidator]);
const handleChange = (checked: boolean) => {
fetcher.submit(
{
action: "update-vercel-sync",
key: envVarKey,
environmentType,
syncEnabled: checked.toString(),
},
{ method: "post" }
);
};
// If pull env vars is disabled for this environment, show disabled state
if (!pullEnvVarsEnabledForEnv) {
return (
<SimpleTooltip
button={
<Switch
variant="small"
checked={false}
disabled
onCheckedChange={() => {}}
/>
}
content="Enable 'Pull env vars before build' for this environment in Vercel settings."
/>
);
}
return (
<Switch
variant="small"
checked={syncEnabled}
disabled={isLoading}
onCheckedChange={handleChange}
/>
);
}
@@ -38,12 +38,20 @@ import {
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
import React, { useEffect, useState } from "react";
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useSearchParams } from "@remix-run/react";
import { useEnvironment } from "~/hooks/useEnvironment";
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
import { type BuildSettings } from "~/v3/buildSettings";
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import {
VercelSettingsPanel,
VercelOnboardingModal,
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { useTypedFetcher } from "remix-typedjson";
export const meta: MetaFunction = () => {
return [
@@ -92,6 +100,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
githubAppEnabled: gitHubApp.enabled,
buildSettings,
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
});
};
@@ -290,12 +299,121 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const environment = useEnvironment();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [searchParams, setSearchParams] = useSearchParams();
// Vercel onboarding modal state
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
const nextUrl = searchParams.get("next");
const [isModalOpen, setIsModalOpen] = useState(false);
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
// Helper to open modal and ensure query param is present
const openVercelOnboarding = useCallback(() => {
setIsModalOpen(true);
// Ensure query param is present to maintain state during form submissions
if (!hasQueryParam) {
setSearchParams((prev) => {
prev.set("vercelOnboarding", "true");
return prev;
});
}
}, [hasQueryParam, setSearchParams]);
const closeVercelOnboarding = useCallback(() => {
// Remove query param if present
if (hasQueryParam) {
setSearchParams((prev) => {
prev.delete("vercelOnboarding");
return prev;
});
}
// Close modal
setIsModalOpen(false);
}, [hasQueryParam, setSearchParams]);
// When query param is present, handle modal opening
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
useEffect(() => {
if (hasQueryParam && vercelIntegrationEnabled) {
// Ensure query param is present and modal is open
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data is loaded, ensure modal is open (query param takes precedence)
if (!isModalOpen) {
openVercelOnboarding();
}
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
// Load onboarding data
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
);
}
} else if (!hasQueryParam && isModalOpen) {
// Query param removed but modal is open, close modal
setIsModalOpen(false);
}
}, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
// Ensure modal stays open when query param is present (even after data reloads)
// This is a safeguard to prevent the modal from closing during form submissions
useEffect(() => {
if (hasQueryParam && !isModalOpen) {
// Query param is present but modal is closed, open it
// This ensures the modal stays open during the onboarding flow
openVercelOnboarding();
}
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
// When data finishes loading (from query param), ensure modal is open
useEffect(() => {
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data loaded and query param is present, ensure modal is open
if (!isModalOpen) {
openVercelOnboarding();
}
}
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
// Track if we're waiting for data from button click (not query param)
const waitingForButtonClickRef = useRef(false);
// Handle opening modal from button click (without query param)
const handleOpenVercelModal = useCallback(() => {
// Add query param to maintain state during form submissions
if (!hasQueryParam) {
setSearchParams((prev) => {
prev.set("vercelOnboarding", "true");
return prev;
});
}
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
// Data already loaded, open modal immediately
openVercelOnboarding();
} else {
// Need to load data first, mark that we're waiting for button click
waitingForButtonClickRef.current = true;
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`
);
}
}, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]);
// When data loads from button click, open modal
useEffect(() => {
if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data loaded from button click, open modal and ensure query param is present
waitingForButtonClickRef.current = false;
openVercelOnboarding();
}
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
@@ -425,6 +543,21 @@ export default function Page() {
</div>
</div>
{vercelIntegrationEnabled && (
<div>
<Header2 spacing>Vercel integration</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<VercelSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
onOpenVercelModal={handleOpenVercelModal}
isLoadingVercelData={vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"}
/>
</div>
</div>
)}
<div>
<Header2 spacing>Build settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
@@ -477,6 +610,29 @@ export default function Page() {
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
{/* Vercel Onboarding Modal */}
{vercelIntegrationEnabled && (
<VercelOnboardingModal
isOpen={isModalOpen}
onClose={closeVercelOnboarding}
onboardingData={vercelFetcher.data?.onboardingData ?? null}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
nextUrl={nextUrl ?? undefined}
onDataReload={(vercelEnvironmentId) => {
vercelFetcher.load(
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${
vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : ""
}`
);
}}
/>
)}
</PageContainer>
);
}
@@ -0,0 +1,375 @@
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { fromPromise } from "neverthrow";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { DialogClose } from "@radix-ui/react-dialog";
import { Button } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header1 } from "~/components/primitives/Headers";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "~/components/primitives/Table";
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
import { $transaction, prisma } from "~/db.server";
import { requireOrganization } from "~/services/org.server";
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
import { logger } from "~/services/logger.server";
import { TrashIcon } from "@heroicons/react/20/solid";
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
import { LinkButton } from "~/components/primitives/Buttons";
function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const url = new URL(request.url);
const configurationId = url.searchParams.get("configurationId") ?? undefined;
const { organization } = await requireOrganization(request, organizationSlug);
// Find Vercel integration for this organization
let vercelIntegration = await prisma.organizationIntegration.findFirst({
where: {
organizationId: organization.id,
service: "VERCEL",
deletedAt: null,
// If configurationId is provided, filter by it in integrationData
...(configurationId && {
integrationData: {
path: ["installationId"],
equals: configurationId,
},
}),
},
include: {
tokenReference: true,
},
});
if (!vercelIntegration) {
return typedjson({
organization,
vercelIntegration: null,
connectedProjects: [],
teamId: null,
installationId: null,
});
}
// Get team ID from integrationData
const integrationData = vercelIntegration.integrationData as any;
const teamId = integrationData?.teamId ?? null;
const installationId = integrationData?.installationId ?? null;
// Get all connected projects for this integration
const connectedProjects = await prisma.organizationProjectIntegration.findMany({
where: {
organizationIntegrationId: vercelIntegration.id,
deletedAt: null,
},
include: {
project: {
select: {
id: true,
slug: true,
name: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return typedjson({
organization,
vercelIntegration,
connectedProjects,
teamId,
installationId,
});
};
const ActionSchema = z.object({
intent: z.literal("uninstall"),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { organization, userId } = await requireOrganization(request, organizationSlug);
const formData = await request.formData();
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
if (!result.success) {
return json({ error: "Invalid action" }, { status: 400 });
}
// Find Vercel integration
const vercelIntegration = await prisma.organizationIntegration.findFirst({
where: {
organizationId: organization.id,
service: "VERCEL",
deletedAt: null,
},
include: {
tokenReference: true,
},
});
if (!vercelIntegration) {
return json({ error: "Vercel integration not found" }, { status: 404 });
}
// Uninstall from Vercel side
const uninstallResult = await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration);
if (uninstallResult.isErr()) {
logger.error("Failed to uninstall Vercel integration", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: vercelIntegration.id,
error: uninstallResult.error.message,
});
return json(
{ error: "Failed to uninstall Vercel integration. Please try again." },
{ status: 500 }
);
}
// Soft-delete the integration and all connected projects in a transaction
const txResult = await fromPromise(
$transaction(prisma, async (tx) => {
await tx.organizationProjectIntegration.updateMany({
where: {
organizationIntegrationId: vercelIntegration.id,
deletedAt: null,
},
data: { deletedAt: new Date() },
});
await tx.organizationIntegration.update({
where: { id: vercelIntegration.id },
data: { deletedAt: new Date() },
});
}),
(error) => error
);
if (txResult.isErr()) {
logger.error("Failed to soft-delete Vercel integration records", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: vercelIntegration.id,
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
});
return json(
{ error: "Failed to uninstall Vercel integration. Please try again." },
{ status: 500 }
);
}
if (uninstallResult.value.authInvalid) {
logger.warn("Vercel integration uninstalled with auth error - token invalid", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: vercelIntegration.id,
});
} else {
logger.info("Vercel integration uninstalled successfully", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: vercelIntegration.id,
});
}
// Redirect back to organization settings
return redirect(`/orgs/${organizationSlug}/settings`);
};
export default function VercelIntegrationPage() {
const { organization, vercelIntegration, connectedProjects, teamId, installationId } =
useTypedLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isUninstalling = navigation.state === "submitting" &&
navigation.formData?.get("intent") === "uninstall";
if (!vercelIntegration) {
return (
<PageContainer>
<PageBody>
<div className="flex flex-col items-center justify-center py-8">
<Header1>No Vercel Integration Found</Header1>
<Paragraph className="mt-2 text-center text-text-dimmed">
This organization doesn't have a Vercel integration configured.
</Paragraph>
</div>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<PageBody>
<div className="mb-8">
<Header1>Vercel Integration</Header1>
<Paragraph className="mt-2 text-text-dimmed">
Manage your organization's Vercel integration and connected projects.
</Paragraph>
</div>
{/* Integration Info Section */}
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
{teamId && (
<div>
<span className="font-medium">Vercel Team ID:</span> {teamId}
</div>
)}
{installationId && (
<div>
<span className="font-medium">Installation ID:</span> {installationId}
</div>
)}
<div>
<span className="font-medium">Installed:</span>{" "}
{formatDate(new Date(vercelIntegration.createdAt))}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button
variant="danger/medium"
LeadingIcon={TrashIcon}
disabled={isUninstalling}
>
Remove Integration
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Vercel Integration</DialogTitle>
</DialogHeader>
<DialogDescription>
This will permanently remove the Vercel integration and disconnect all projects.
This action cannot be undone.
</DialogDescription>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="intent" value="uninstall" />
<Button
variant="danger/medium"
LeadingIcon={TrashIcon}
type="submit"
disabled={isUninstalling}
>
{isUninstalling ? "Removing..." : "Remove Integration"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</DialogContent>
</Dialog>
{actionData?.error && (
<Paragraph variant="small" className="text-error">
{actionData.error}
</Paragraph>
)}
</div>
</div>
</div>
{/* Connected Projects Section */}
<div>
<h2 className="mb-4 text-lg font-medium text-text-bright">
Connected Projects ({connectedProjects.length})
</h2>
{connectedProjects.length === 0 ? (
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
<Paragraph className="text-text-dimmed">
No projects are currently connected to this Vercel integration.
</Paragraph>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Project Name</TableHeaderCell>
<TableHeaderCell>Vercel Project ID</TableHeaderCell>
<TableHeaderCell>Connected</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{connectedProjects.map((projectIntegration) => (
<TableRow key={projectIntegration.id}>
<TableCell>{projectIntegration.project.name}</TableCell>
<TableCell className="font-mono text-xs">
{projectIntegration.externalEntityId}
</TableCell>
<TableCell>
{formatDate(new Date(projectIntegration.createdAt))}
</TableCell>
<TableCell>
<LinkButton
variant="minimal/small"
to={v3ProjectSettingsPath(
organization,
projectIntegration.project,
{ slug: "prod" } // Default to production environment
)}
>
Configure
</LinkButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</PageBody>
</PageContainer>
);
}
@@ -32,6 +32,7 @@ import {
selectPlanPath,
v3ProjectPath,
} from "~/utils/pathBuilder";
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@@ -103,6 +104,12 @@ export const action: ActionFunction = async ({ request, params }) => {
return json(submission);
}
// Check for Vercel integration params in URL
const url = new URL(request.url);
const code = url.searchParams.get("code");
const configurationId = url.searchParams.get("configurationId");
const next = url.searchParams.get("next");
try {
const project = await createProject({
organizationSlug: organizationSlug,
@@ -111,6 +118,44 @@ export const action: ActionFunction = async ({ request, params }) => {
version: submission.value.projectVersion,
});
// If this is a Vercel integration flow, generate state and redirect to connect
if (code && configurationId) {
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: "prod",
archivedAt: null,
},
});
if (!environment) {
return redirectWithErrorMessage(
newProjectPath({ slug: organizationSlug }),
request,
"Failed to find project environment."
);
}
const state = await generateVercelOAuthState({
organizationId: project.organization.id,
projectId: project.id,
environmentSlug: environment.slug,
organizationSlug: project.organization.slug,
projectSlug: project.slug,
});
const params = new URLSearchParams({
state,
code,
configurationId,
origin: "marketplace",
});
if (next) {
params.set("next", next);
}
return redirect(`/vercel/connect?${params.toString()}`);
}
return redirectWithSuccessMessage(
v3ProjectPath(project.organization, project),
request,
@@ -69,6 +69,27 @@ export const action: ActionFunction = async ({ request }) => {
});
}
// Preserve Vercel integration params if present
const url = new URL(request.url);
const code = url.searchParams.get("code");
const configurationId = url.searchParams.get("configurationId");
const integration = url.searchParams.get("integration");
const next = url.searchParams.get("next");
if (code && configurationId && integration === "vercel") {
// Redirect to projects/new with params preserved
const params = new URLSearchParams({
code,
configurationId,
integration,
});
if (next) {
params.set("next", next);
}
const redirectUrl = `${organizationPath(organization)}/projects/new?${params.toString()}`;
return redirect(redirectUrl);
}
return redirect(organizationPath(organization));
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
@@ -39,6 +39,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
tasks: true,
},
},
integrationDeployments: true,
},
});
@@ -54,6 +55,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
version: deployment.version,
imageReference: deployment.imageReference,
imagePlatform: deployment.imagePlatform,
commitSHA: deployment.commitSHA,
externalBuildData:
deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"],
errorData: deployment.errorData as GetDeploymentResponseBody["errorData"],
@@ -69,5 +71,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
})),
}
: undefined,
integrationDeployments:
deployment.integrationDeployments.length > 0
? deployment.integrationDeployments.map((id) => ({
id: id.id,
integrationName: id.integrationName,
integrationDeploymentId: id.integrationDeploymentId,
commitSHA: id.commitSHA,
createdAt: id.createdAt,
}))
: undefined,
} satisfies GetDeploymentResponseBody);
}
@@ -0,0 +1,147 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { fromPromise } from "neverthrow";
import { z } from "zod";
import { prisma } from "~/db.server";
import { apiCors } from "~/utils/apiCors";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
const ParamsSchema = z.object({
organizationSlug: z.string(),
projectParam: z.string(),
});
/**
* API endpoint to retrieve connected Vercel projects for a Trigger.dev project.
*
* GET /api/v1/orgs/:organizationSlug/projects/:projectParam/vercel/projects
*
* Returns:
* - vercelProject: The connected Vercel project details (if any)
* - config: The Vercel integration configuration
* - syncEnvVarsMapping: The environment variable sync mapping
*/
export async function loader({ request, params }: LoaderFunctionArgs) {
// Handle CORS
if (request.method === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return apiCors(
request,
json({ error: "Invalid or Missing Access Token" }, { status: 401 })
);
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return apiCors(
request,
json({ error: "Invalid parameters" }, { status: 400 })
);
}
const { organizationSlug, projectParam } = parsedParams.data;
const result = await fromPromise(
(async () => {
// Find the project, verifying org membership
const project = await prisma.project.findFirst({
where: {
slug: projectParam,
organization: {
slug: organizationSlug,
members: {
some: {
userId: authenticationResult.userId,
},
},
},
deletedAt: null,
},
select: {
id: true,
name: true,
slug: true,
organizationId: true,
},
});
if (!project) {
return { type: "not_found" as const };
}
// Get Vercel integration for the project
const vercelService = new VercelIntegrationService();
const integration = await vercelService.getVercelProjectIntegration(project.id);
return { type: "success" as const, project, integration };
})(),
(error) => error
);
if (result.isErr()) {
logger.error("Failed to fetch Vercel projects", {
error: result.error,
organizationSlug,
projectParam,
});
return apiCors(
request,
json({ error: "Internal server error" }, { status: 500 })
);
}
if (result.value.type === "not_found") {
return apiCors(
request,
json({ error: "Project not found" }, { status: 404 })
);
}
const { project, integration } = result.value;
if (!integration) {
return apiCors(
request,
json({
connected: false,
vercelProject: null,
config: null,
syncEnvVarsMapping: null,
})
);
}
const { parsedIntegrationData } = integration;
return apiCors(
request,
json({
connected: true,
vercelProject: {
id: parsedIntegrationData.vercelProjectId,
name: parsedIntegrationData.vercelProjectName,
teamId: parsedIntegrationData.vercelTeamId,
},
config: {
atomicBuilds: parsedIntegrationData.config.atomicBuilds,
pullEnvVarsBeforeBuild: parsedIntegrationData.config.pullEnvVarsBeforeBuild,
vercelStagingEnvironment: parsedIntegrationData.config.vercelStagingEnvironment,
},
syncEnvVarsMapping: parsedIntegrationData.syncEnvVarsMapping,
triggerProject: {
id: project.id,
name: project.name,
slug: project.slug,
},
})
);
}
@@ -41,10 +41,13 @@ export async function action({ params, request }: ActionFunctionArgs) {
const result = await repository.create(environment.project.id, {
override: typeof body.override === "boolean" ? body.override : false,
environmentIds: [environment.id],
// Pass parent environment ID so new variables can inherit isSecret from parent
parentEnvironmentId: environment.parentEnvironmentId ?? undefined,
variables: Object.entries(body.variables).map(([key, value]) => ({
key,
value,
})),
lastUpdatedBy: body.source,
});
// Only sync parent variables if this is a branch environment
@@ -56,6 +59,7 @@ export async function action({ params, request }: ActionFunctionArgs) {
key,
value,
})),
lastUpdatedBy: body.source,
});
let childFailure = !result.success ? result : undefined;
@@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { redirectCookie } from "./auth.github";
import { sanitizeRedirectPath } from "~/utils";
@@ -17,7 +18,6 @@ export let loader: LoaderFunction = async ({ request }) => {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
});
// manually get the session
const session = await getSession(request.headers.get("cookie"));
const userRecord = await prisma.user.findFirst({
@@ -49,12 +49,13 @@ export let loader: LoaderFunction = async ({ request }) => {
return redirect("/login/mfa", { headers });
}
// and store the user data
session.set(authenticator.sessionKey, auth);
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
await trackAndClearReferralSource(request, auth.userId, headers);
return redirect(redirectTo, { headers });
};
@@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { redirectCookie } from "./auth.google";
import { sanitizeRedirectPath } from "~/utils";
@@ -17,7 +18,6 @@ export let loader: LoaderFunction = async ({ request }) => {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
});
// manually get the session
const session = await getSession(request.headers.get("cookie"));
const userRecord = await prisma.user.findFirst({
@@ -49,13 +49,14 @@ export let loader: LoaderFunction = async ({ request }) => {
return redirect("/login/mfa", { headers });
}
// and store the user data
session.set(authenticator.sessionKey, auth);
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
await trackAndClearReferralSource(request, auth.userId, headers);
return redirect(redirectTo, { headers });
};
@@ -25,6 +25,7 @@ import { redirectWithSuccessMessage } from "~/models/message.server";
import { updateUser } from "~/models/user.server";
import { requireUserId } from "~/services/session.server";
import { rootPath } from "~/utils/pathBuilder";
import { getVercelInstallParams } from "~/v3/vercel";
function createSchema(
constraints: {
@@ -105,7 +106,24 @@ export const action: ActionFunction = async ({ request }) => {
referralSource: submission.value.referralSource,
});
return redirectWithSuccessMessage(rootPath(), request, "Your details have been updated.");
// Preserve Vercel integration params if present
const vercelParams = getVercelInstallParams(request);
let redirectUrl = rootPath();
if (vercelParams) {
// Redirect to orgs/new with params preserved
const params = new URLSearchParams({
code: vercelParams.code,
configurationId: vercelParams.configurationId,
integration: "vercel",
});
if (vercelParams.next) {
params.set("next", vercelParams.next);
}
redirectUrl = `/orgs/new?${params.toString()}`;
}
return redirectWithSuccessMessage(redirectUrl, request, "Your details have been updated.");
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
@@ -167,7 +167,7 @@ export default function LoginPage() {
<div className="relative w-full">
{data.lastAuthMethod === "email" && <LastUsedBadge />}
<LinkButton
to="/login/magic"
to={data.redirectTo ? `/login/magic?redirectTo=${encodeURIComponent(data.redirectTo)}` : "/login/magic"}
variant="secondary/extra-large"
fullWidth
data-action="continue with email"
+14 -1
View File
@@ -22,6 +22,7 @@ import { Spinner } from "~/components/primitives/Spinner";
import { TextLink } from "~/components/primitives/TextLink";
import { authenticator } from "~/services/auth.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { setRedirectTo, commitSession as commitRedirectSession } from "~/services/redirectTo.server";
import {
checkMagicLinkEmailRateLimit,
checkMagicLinkEmailDailyRateLimit,
@@ -59,6 +60,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
const session = await getUserSession(request);
const error = session.get("auth:error");
// Get redirectTo from URL params and store in session if present
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirectTo");
const headers = new Headers();
if (redirectTo) {
const redirectSession = await setRedirectTo(request, redirectTo);
headers.append("Set-Cookie", await commitRedirectSession(redirectSession));
}
let magicLinkError: string | undefined;
if (error) {
if ("message" in error) {
@@ -68,13 +79,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
}
headers.append("Set-Cookie", await commitSession(session));
return typedjson(
{
magicLinkSent: session.has("triggerdotdev:magiclink"),
magicLinkError,
},
{
headers: { "Set-Cookie": await commitSession(session) },
headers,
}
);
}
+8 -5
View File
@@ -26,6 +26,7 @@ import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuth
import { redirectWithErrorMessage, redirectBackWithErrorMessage } from "~/models/message.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
export const meta: MetaFunction = ({ matches }) => {
const parentMeta = matches
@@ -160,11 +161,13 @@ async function completeLogin(request: Request, session: Session, userId: string)
session.unset("pending-mfa-user-id");
session.unset("pending-mfa-redirect-to");
return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(authSession),
},
});
const headers = new Headers();
headers.append("Set-Cookie", await sessionStorage.commitSession(authSession));
headers.append("Set-Cookie", await commitSession(session));
await trackAndClearReferralSource(request, userId, headers);
return redirect(redirectTo, { headers });
}
export default function LoginMfaPage() {
+3
View File
@@ -6,6 +6,7 @@ import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { getRedirectTo } from "~/services/redirectTo.server";
import { commitSession, getSession } from "~/services/sessionStorage.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
export async function loader({ request }: LoaderFunctionArgs) {
const redirectTo = await getRedirectTo(request);
@@ -53,5 +54,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
await trackAndClearReferralSource(request, auth.userId, headers);
return redirect(redirectTo ?? "/", { headers });
}
@@ -2,8 +2,10 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
import { regenerateApiKey } from "~/models/api-key.server";
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server";
import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.server";
const ParamsSchema = z.object({
environmentId: z.string(),
@@ -19,9 +21,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { environmentId } = ParamsSchema.parse(params);
const formData = await request.formData();
const syncToVercel = formData.get("syncToVercel") === "on";
try {
const updatedEnvironment = await regenerateApiKey({ userId, environmentId });
// Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT
if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") {
await syncApiKeyToVercel(
updatedEnvironment.projectId,
updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW",
updatedEnvironment.apiKey
);
}
return jsonWithSuccessMessage(
{ ok: true },
request,
@@ -37,3 +51,27 @@ export async function action({ request, params }: ActionFunctionArgs) {
);
}
}
/**
* Sync the API key to Vercel.
* Errors are logged but won't fail the API key regeneration.
*/
async function syncApiKeyToVercel(
projectId: string,
environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT",
apiKey: string
): Promise<void> {
const result = await VercelIntegrationRepository.syncSingleApiKeyToVercel({
projectId,
environmentType,
apiKey,
});
if (result.isErr()) {
logger.warn("syncSingleApiKeyToVercel returned failure", {
projectId,
environmentType,
error: result.error.message,
});
}
}
@@ -330,12 +330,15 @@ export function ConnectGitHubRepoModal({
projectSlug,
environmentSlug,
redirectUrl,
preventDismiss,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
redirectUrl?: string;
/** When true, prevents closing the modal via Escape key or clicking outside */
preventDismiss?: boolean;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
@@ -385,13 +388,34 @@ export function ConnectGitHubRepoModal({
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog
open={isModalOpen}
onOpenChange={(open) => {
// When preventDismiss is true, only allow opening, not closing
if (preventDismiss && !open) {
return;
}
setIsModalOpen(open);
}}
>
<DialogTrigger asChild>
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
Connect GitHub repo
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent
showCloseButton={!preventDismiss}
onInteractOutside={(e) => {
if (preventDismiss) {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
if (preventDismiss) {
e.preventDefault();
}
}}
>
<DialogHeader>Connect GitHub repository</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" action={actionUrl} {...form.props} className="w-full">
@@ -514,9 +538,11 @@ export function ConnectGitHubRepoModal({
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
preventDismiss ? undefined : (
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
)
}
/>
</Fieldset>
@@ -0,0 +1,926 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/20/solid";
import {
Form,
useActionData,
useFetcher,
useNavigation,
useLocation,
} from "@remix-run/react";
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
json,
} from "@remix-run/server-runtime";
import { typedjson, useTypedFetcher } from "remix-typedjson";
import { z } from "zod";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { DateTime } from "~/components/primitives/DateTime";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings";
import {
redirectBackWithErrorMessage,
redirectWithSuccessMessage,
redirectWithErrorMessage,
} from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import {
VercelSettingsPresenter,
type VercelOnboardingData,
} from "~/presenters/v3/VercelSettingsPresenter.server";
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
import {
type VercelProjectIntegrationData,
type SyncEnvVarsMapping,
type EnvSlug,
envSlugArrayField,
envTypeToSlug,
getAvailableEnvSlugs,
getAvailableEnvSlugsForBuildSettings,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { Result, fromPromise } from "neverthrow";
import { useEffect, useState } from "react";
export type ConnectedVercelProject = {
id: string;
vercelProjectId: string;
vercelProjectName: string;
vercelTeamId: string | null;
integrationData: VercelProjectIntegrationData;
createdAt: Date;
};
const safeJsonParse = Result.fromThrowable(
(val: string) => JSON.parse(val) as Record<string, unknown>,
() => null
);
function parseVercelStagingEnvironment(
value: string | null | undefined
): { environmentId: string; displayName: string } | null {
if (!value) return null;
return safeJsonParse(value).match(
(parsed) => {
if (typeof parsed?.environmentId === "string" && typeof parsed?.displayName === "string") {
return { environmentId: parsed.environmentId, displayName: parsed.displayName };
}
return null;
},
() => null
);
}
const UpdateVercelConfigFormSchema = z.object({
action: z.literal("update-config"),
atomicBuilds: envSlugArrayField,
pullEnvVarsBeforeBuild: envSlugArrayField,
discoverEnvVars: envSlugArrayField,
vercelStagingEnvironment: z.string().nullable().optional(),
});
const DisconnectVercelFormSchema = z.object({
action: z.literal("disconnect"),
});
const CompleteOnboardingFormSchema = z.object({
action: z.literal("complete-onboarding"),
vercelStagingEnvironment: z.string().nullable().optional(),
pullEnvVarsBeforeBuild: envSlugArrayField,
atomicBuilds: envSlugArrayField,
discoverEnvVars: envSlugArrayField,
syncEnvVarsMapping: z.string().optional(),
next: z.string().optional(),
skipRedirect: z.string().optional().transform((val) => val === "true"),
});
const SkipOnboardingFormSchema = z.object({
action: z.literal("skip-onboarding"),
});
const SelectVercelProjectFormSchema = z.object({
action: z.literal("select-vercel-project"),
vercelProjectId: z.string().min(1, "Please select a Vercel project"),
vercelProjectName: z.string().min(1),
});
const UpdateEnvMappingFormSchema = z.object({
action: z.literal("update-env-mapping"),
vercelStagingEnvironment: z.string().nullable().optional(),
});
const DisableAutoAssignFormSchema = z.object({
action: z.literal("disable-auto-assign"),
});
const VercelActionSchema = z.discriminatedUnion("action", [
UpdateVercelConfigFormSchema,
DisconnectVercelFormSchema,
CompleteOnboardingFormSchema,
SkipOnboardingFormSchema,
SelectVercelProjectFormSchema,
UpdateEnvMappingFormSchema,
DisableAutoAssignFormSchema,
]);
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new VercelSettingsPresenter();
const resultOrFail = await presenter.call({
projectId: project.id,
organizationId: project.organizationId,
});
if (resultOrFail.isErr()) {
logger.error("Failed to load Vercel settings", {
url: request.url,
params,
error: resultOrFail.error,
});
throw new Response("Failed to load Vercel settings", { status: 500 });
}
const result = resultOrFail.value;
const url = new URL(request.url);
const needsOnboarding = url.searchParams.get("vercelOnboarding") === "true";
const vercelEnvironmentId = url.searchParams.get("vercelEnvironmentId") || undefined;
let onboardingData: VercelOnboardingData | null = null;
if (needsOnboarding) {
onboardingData = await presenter.getOnboardingData(
project.id,
project.organizationId,
vercelEnvironmentId
);
}
const authInvalid = onboardingData?.authInvalid || result.authInvalid || false;
return typedjson({
...result,
authInvalid,
onboardingData,
organizationSlug,
projectSlug: projectParam,
environmentSlug: envParam,
projectId: project.id,
organizationId: project.organizationId,
});
}
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const formData = await request.formData();
const submission = parse(formData, { schema: VercelActionSchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const settingsPath = v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
const vercelService = new VercelIntegrationService();
const { action: actionType } = submission.value;
switch (actionType) {
case "update-config": {
const {
atomicBuilds,
pullEnvVarsBeforeBuild,
discoverEnvVars,
vercelStagingEnvironment,
} = submission.value;
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
atomicBuilds,
pullEnvVarsBeforeBuild,
discoverEnvVars,
vercelStagingEnvironment: parsedStagingEnv,
});
if (result) {
return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully");
}
return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings");
}
case "disconnect": {
const success = await vercelService.disconnectVercelProject(project.id);
if (success) {
return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected");
}
return redirectWithErrorMessage(settingsPath, request, "Failed to disconnect Vercel project");
}
case "complete-onboarding": {
const {
vercelStagingEnvironment,
pullEnvVarsBeforeBuild,
atomicBuilds,
discoverEnvVars,
syncEnvVarsMapping,
next,
skipRedirect,
} = submission.value;
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
const parsedSyncEnvVarsMapping = syncEnvVarsMapping
? safeJsonParse(syncEnvVarsMapping).unwrapOr(undefined) as SyncEnvVarsMapping | undefined
: undefined;
const result = await vercelService.completeOnboarding(project.id, {
vercelStagingEnvironment: parsedStagingEnv,
pullEnvVarsBeforeBuild,
atomicBuilds,
discoverEnvVars,
syncEnvVarsMapping: parsedSyncEnvVarsMapping,
});
if (result) {
if (skipRedirect) {
return json({ success: true });
}
if (next) {
const sanitizedNext = sanitizeVercelNextUrl(next);
if (sanitizedNext) {
return json({ success: true, redirectTo: sanitizedNext });
}
logger.warn("Rejected next URL - not same-origin or vercel.com", { next });
}
return json({ success: true, redirectTo: settingsPath });
}
return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup");
}
case "update-env-mapping": {
const { vercelStagingEnvironment } = submission.value;
const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment);
const result = await vercelService.updateVercelIntegrationConfig(project.id, {
vercelStagingEnvironment: parsedStagingEnv,
});
if (result) {
return json({ success: true });
}
return json({ success: false, error: "Failed to update environment mapping" }, { status: 400 });
}
case "skip-onboarding": {
return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped");
}
case "select-vercel-project": {
const { vercelProjectId, vercelProjectName } = submission.value;
const selectResult = await fromPromise(
vercelService.selectVercelProject({
organizationId: project.organizationId,
projectId: project.id,
vercelProjectId,
vercelProjectName,
userId,
}),
(error) => error
);
if (selectResult.isErr()) {
logger.error("Failed to select Vercel project", { error: selectResult.error });
return json({
error: "Failed to connect Vercel project. Please try again.",
});
}
const { integration, syncResult } = selectResult.value;
if (!syncResult.success && syncResult.errors.length > 0) {
logger.warn("Failed to send trigger secrets to Vercel", {
projectId: project.id,
vercelProjectId,
errors: syncResult.errors,
});
}
return json({
success: true,
integrationId: integration.id,
syncErrors: syncResult.errors,
});
}
case "disable-auto-assign": {
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(
project.id
);
if (!orgIntegration) {
return redirectWithErrorMessage(settingsPath, request, "No Vercel integration found");
}
const projectIntegration = await vercelService.getVercelProjectIntegration(project.id);
if (!projectIntegration) {
return redirectWithErrorMessage(settingsPath, request, "No Vercel project connected");
}
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
const disableResult = await VercelIntegrationRepository.getVercelClient(orgIntegration)
.andThen((client) =>
VercelIntegrationRepository.disableAutoAssignCustomDomains(
client,
projectIntegration.parsedIntegrationData.vercelProjectId,
teamId
)
);
if (disableResult.isErr()) {
logger.error("Failed to disable auto-assign custom domains", { error: disableResult.error });
return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains");
}
return redirectWithSuccessMessage(settingsPath, request, "Auto-assign custom domains disabled");
}
default: {
submission.value satisfies never;
return redirectBackWithErrorMessage(request, "Failed to process request");
}
}
}
function VercelConnectionPrompt({
organizationSlug,
projectSlug,
environmentSlug,
hasOrgIntegration,
isGitHubConnected,
onOpenModal,
isLoading,
}: {
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
hasOrgIntegration: boolean;
isGitHubConnected: boolean;
onOpenModal?: () => void;
isLoading?: boolean;
}) {
const installPath = vercelAppInstallPath(organizationSlug, projectSlug);
const handleConnectProject = () => {
if (onOpenModal) {
onOpenModal();
}
};
const isLoadingProjects = isLoading ?? false;
const isDisabled = isLoadingProjects || !onOpenModal;
return (
<Fieldset>
<InputGroup fullWidth>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
{hasOrgIntegration ? (
<>
<Button
variant="secondary/medium"
onClick={handleConnectProject}
disabled={isDisabled}
LeadingIcon={
isLoadingProjects
? () => <SpinnerWhite className="size-4" />
: () => <VercelLogo className="size-4 -mx-1" />
}
>
{isLoadingProjects ? "Loading projects..." : "Connect Vercel project"}
</Button>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> Vercel app is installed
</span>
{!onOpenModal && (
<span className="text-xs text-amber-400">
Please reconnect Vercel to continue
</span>
)}
</>
) : (
<>
<LinkButton
to={installPath}
variant="secondary/medium"
LeadingIcon={() => <VercelLogo className="size-4 -mx-1" />}
>
Install Vercel app
</LinkButton>
</>
)}
</div>
</div>
</InputGroup>
</Fieldset>
);
}
function VercelAuthInvalidBanner({
organizationSlug,
projectSlug,
}: {
organizationSlug: string;
projectSlug: string;
}) {
const installUrl = vercelAppInstallPath(organizationSlug, projectSlug);
return (
<Callout variant="error" className="mb-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<p className="font-sans text-sm font-medium text-text-bright mb-2">
Vercel connection expired
</p>
<p className="font-sans text-xs text-text-dimmed mb-3">
Your Vercel access token has expired or been revoked. Please reconnect to restore functionality.
</p>
<LinkButton
to={installUrl}
variant="minimal/small"
className="bg-error/10 hover:bg-error/20 text-error border-error/20"
>
Reconnect Vercel
</LinkButton>
</div>
</div>
</Callout>
);
}
function VercelGitHubWarning() {
return (
<Callout variant="warning" className="mb-4">
<p className="font-sans text-xs font-normal text-text-dimmed">
GitHub integration is not connected. Vercel integration cannot sync environment variables and
link deployments without a properly installed GitHub integration.
</p>
</Callout>
);
}
function envSlugLabel(slug: EnvSlug): string {
switch (slug) {
case "prod":
return "Production";
case "stg":
return "Staging";
case "preview":
return "Preview";
case "dev":
return "Development";
}
}
function ConnectedVercelProjectForm({
connectedProject,
hasStagingEnvironment,
hasPreviewEnvironment,
customEnvironments,
autoAssignCustomDomains,
organizationSlug,
projectSlug,
environmentSlug,
}: {
connectedProject: ConnectedVercelProject;
hasStagingEnvironment: boolean;
hasPreviewEnvironment: boolean;
customEnvironments: Array<{ id: string; slug: string }>;
autoAssignCustomDomains: boolean | null;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasConfigChanges, setHasConfigChanges] = useState(false);
const [configValues, setConfigValues] = useState({
atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [],
pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [],
discoverEnvVars: connectedProject.integrationData.config.discoverEnvVars ?? [],
vercelStagingEnvironment:
connectedProject.integrationData.config.vercelStagingEnvironment ?? null,
});
const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? [];
const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [];
const originalDiscoverEnvVars = connectedProject.integrationData.config.discoverEnvVars ?? [];
const originalStagingEnv = connectedProject.integrationData.config.vercelStagingEnvironment ?? null;
useEffect(() => {
const atomicBuildsChanged =
JSON.stringify([...configValues.atomicBuilds].sort()) !==
JSON.stringify([...originalAtomicBuilds].sort());
const pullEnvVarsChanged =
JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !==
JSON.stringify([...originalPullEnvVars].sort());
const discoverEnvVarsChanged =
JSON.stringify([...configValues.discoverEnvVars].sort()) !==
JSON.stringify([...originalDiscoverEnvVars].sort());
const stagingEnvChanged = configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId;
setHasConfigChanges(atomicBuildsChanged || pullEnvVarsChanged || discoverEnvVarsChanged || stagingEnvChanged);
}, [configValues, originalAtomicBuilds, originalPullEnvVars, originalDiscoverEnvVars, originalStagingEnv]);
const [configForm, fields] = useForm({
id: "update-vercel-config",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateVercelConfigFormSchema,
});
},
});
const isConfigLoading =
navigation.formData?.get("action") === "update-config" &&
(navigation.state === "submitting" || navigation.state === "loading");
const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug);
const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment);
const availableEnvSlugsForBuildSettings = getAvailableEnvSlugsForBuildSettings(hasStagingEnvironment, hasPreviewEnvironment);
const formatSelectedEnvs = (selected: EnvSlug[], availableSlugs: EnvSlug[] = availableEnvSlugs): string => {
if (selected.length === 0) return "None selected";
if (selected.length === availableSlugs.length) return "All environments";
return selected.map(envSlugLabel).join(", ");
};
return (
<>
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
<div className="flex items-center gap-2">
<VercelLogo className="size-4" />
<span className="max-w-52 truncate text-sm text-text-bright">
{connectedProject.vercelProjectName}
</span>
<span className="text-xs text-text-dimmed">
<DateTime
date={connectedProject.createdAt}
includeTime={false}
includeSeconds={false}
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="minimal/small">Disconnect</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect Vercel project</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedProject.vercelProjectName}</span>?
This will stop pulling environment variables and disable atomic deployments.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disconnect" />
<Button type="submit" variant="danger/medium">
Disconnect project
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</div>
{/* Configuration form */}
<Form method="post" action={actionUrl} {...configForm.props}>
<input
type="hidden"
name="atomicBuilds"
value={JSON.stringify(configValues.atomicBuilds)}
/>
<input
type="hidden"
name="pullEnvVarsBeforeBuild"
value={JSON.stringify(configValues.pullEnvVarsBeforeBuild)}
/>
<input
type="hidden"
name="discoverEnvVars"
value={JSON.stringify(configValues.discoverEnvVars)}
/>
<input
type="hidden"
name="vercelStagingEnvironment"
value={configValues.vercelStagingEnvironment ? JSON.stringify(configValues.vercelStagingEnvironment) : ""}
/>
<Fieldset>
<InputGroup fullWidth>
<div className="flex flex-col gap-4">
{/* Staging environment mapping */}
{hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && (
<div>
<Label>Map Vercel environment to Staging</Label>
<Hint className="mb-2">
Select which custom Vercel environment should map to Trigger.dev's Staging
environment.
</Hint>
<Select
value={configValues.vercelStagingEnvironment?.environmentId || ""}
setValue={(value) => {
if (!Array.isArray(value)) {
const env = customEnvironments?.find((e) => e.id === value);
setConfigValues((prev) => ({
...prev,
vercelStagingEnvironment: env
? { environmentId: env.id, displayName: env.slug }
: null,
}));
}
}}
items={[{ id: "", slug: "None" }, ...customEnvironments]}
variant="tertiary/small"
placeholder="Select environment"
dropdownIcon
text={configValues.vercelStagingEnvironment?.displayName || "None"}
>
{[
<SelectItem key="" value="">
None
</SelectItem>,
...customEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
{env.slug}
</SelectItem>
)),
]}
</Select>
</div>
)}
<BuildSettingsFields
availableEnvSlugs={availableEnvSlugsForBuildSettings}
pullEnvVarsBeforeBuild={configValues.pullEnvVarsBeforeBuild}
onPullEnvVarsChange={(slugs) =>
setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs }))
}
discoverEnvVars={configValues.discoverEnvVars}
onDiscoverEnvVarsChange={(slugs) =>
setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs }))
}
atomicBuilds={configValues.atomicBuilds}
onAtomicBuildsChange={(slugs) =>
setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs }))
}
envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`}
/>
{/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */}
{autoAssignCustomDomains !== false &&
configValues.atomicBuilds.includes("prod") && (
<Callout variant="warning">
<div className="flex flex-col gap-2">
<p className="font-sans text-xs font-normal text-text-dimmed">
Atomic deployments require the "Auto-assign Custom Domains" setting to be
disabled on your Vercel project. Without this, Vercel will promote
deployments before Trigger.dev is ready.
</p>
<Form method="post" action={actionUrl}>
<input type="hidden" name="action" value="disable-auto-assign" />
<Button
type="submit"
variant="tertiary/small"
disabled={
navigation.formData?.get("action") === "disable-auto-assign" &&
(navigation.state === "submitting" || navigation.state === "loading")
}
LeadingIcon={
navigation.formData?.get("action") === "disable-auto-assign" &&
(navigation.state === "submitting" || navigation.state === "loading")
? SpinnerWhite
: undefined
}
>
Disable auto-assign custom domains
</Button>
</Form>
</div>
</Callout>
)}
</div>
<FormError>{configForm.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-config"
variant="secondary/small"
disabled={isConfigLoading || !hasConfigChanges}
LeadingIcon={isConfigLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</>
);
}
function VercelSettingsPanel({
organizationSlug,
projectSlug,
environmentSlug,
onOpenVercelModal,
isLoadingVercelData,
}: {
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
onOpenVercelModal?: () => void;
isLoadingVercelData?: boolean;
}) {
const fetcher = useTypedFetcher<typeof loader>();
const location = useLocation();
const data = fetcher.data;
const [hasError, setHasError] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
useEffect(() => {
if (!data?.authInvalid && !hasError && !data && !hasFetched) {
fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug));
setHasFetched(true);
}
}, [organizationSlug, projectSlug, environmentSlug, data?.authInvalid, hasError, data, hasFetched]);
useEffect(() => {
if (hasFetched && fetcher.state === "idle" && fetcher.data === undefined && !hasError) {
setHasError(true);
}
}, [fetcher.state, fetcher.data, hasError, hasFetched]);
if (hasError) {
return (
<div className="rounded-sm border border-rose-500/40 bg-rose-500/10 p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 text-rose-500 flex-shrink-0" />
<div>
<p className="font-medium text-rose-400">Failed to load Vercel settings</p>
<p className="text-sm text-rose-300 mt-1">
There was an error loading the Vercel integration settings. Please refresh the page to try again.
</p>
</div>
</div>
</div>
);
}
if (fetcher.state === "loading" && !data) {
return (
<div className="flex items-center gap-2 text-text-dimmed">
<SpinnerWhite className="size-4" />
<span className="text-sm">Loading Vercel settings...</span>
</div>
);
}
if (!data || !data.enabled) {
return null;
}
const showGitHubWarning = data.connectedProject && !data.isGitHubConnected;
const showAuthInvalid = data.authInvalid || data.onboardingData?.authInvalid;
if (data.connectedProject) {
return (
<>
{showAuthInvalid && <VercelAuthInvalidBanner organizationSlug={organizationSlug} projectSlug={projectSlug} />}
{showGitHubWarning && <VercelGitHubWarning />}
{!showAuthInvalid && (<ConnectedVercelProjectForm
connectedProject={data.connectedProject}
hasStagingEnvironment={data.hasStagingEnvironment}
hasPreviewEnvironment={data.hasPreviewEnvironment}
customEnvironments={data.customEnvironments}
autoAssignCustomDomains={data.autoAssignCustomDomains ?? null}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
/>)}
</>
);
}
return (
<div className="flex flex-col gap-2">
{showAuthInvalid && <VercelAuthInvalidBanner organizationSlug={organizationSlug} projectSlug={projectSlug} />}
{!showAuthInvalid && (
<>
<VercelConnectionPrompt
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
hasOrgIntegration={data.hasOrgIntegration}
isGitHubConnected={data.isGitHubConnected}
onOpenModal={showAuthInvalid ? undefined : onOpenVercelModal}
isLoading={isLoadingVercelData}
/>
<Hint>
{data.hasOrgIntegration
? "Connect your Vercel project to pull environment variables and trigger builds automatically."
: "Install the Vercel app to connect your projects and pull environment variables."}
</Hint>
{!data.isGitHubConnected && (
<Hint>
GitHub integration is not connected. Vercel integration cannot sync environment variables and
link deployments without a properly installed GitHub integration.
</Hint>
)}
</>
)}
</div>
);
}
import { VercelOnboardingModal } from "~/components/integrations/VercelOnboardingModal";
export { VercelSettingsPanel, VercelOnboardingModal };
+78
View File
@@ -0,0 +1,78 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { z } from "zod";
import { logger } from "~/services/logger.server";
import { getUserId } from "~/services/session.server";
import { setReferralSourceCookie } from "~/services/referralSource.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server";
const VercelCallbackSchema = z
.object({
code: z.string().optional(),
state: z.string().optional(),
error: z.string().optional(),
error_description: z.string().optional(),
configurationId: z.string().optional(),
next: z.string().optional()
})
.passthrough();
export async function loader({ request }: LoaderFunctionArgs) {
if (request.method.toUpperCase() !== "GET") {
throw new Response("Method Not Allowed", { status: 405 });
}
const userId = await getUserId(request);
if (!userId) {
const currentUrl = new URL(request.url);
const redirectTo = `${currentUrl.pathname}${currentUrl.search}`;
const referralCookie = await setReferralSourceCookie("vercel");
const headers = new Headers();
headers.append("Set-Cookie", referralCookie);
throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, { headers });
}
const url = requestUrl(request);
const parsed = VercelCallbackSchema.safeParse(Object.fromEntries(url.searchParams));
if (!parsed.success) {
logger.error("Invalid Vercel callback params", { error: parsed.error });
throw new Response("Invalid callback parameters", { status: 400 });
}
const { code, state, error, error_description, configurationId, next: rawNextUrl } = parsed.data;
// Sanitize the `next` parameter to prevent open redirects
const nextUrl = sanitizeVercelNextUrl(rawNextUrl);
if (error) {
logger.error("Vercel OAuth error", { error, error_description });
throw new Response("Vercel OAuth error", { status: 500 });
}
if (!code) {
logger.error("Missing authorization code from Vercel callback");
throw new Response("Missing authorization code", { status: 400 });
}
// Route with state: dashboard-invoked flow
if (state) {
const params = new URLSearchParams({ state, code, origin: "dashboard" });
if (configurationId) params.set("configurationId", configurationId);
if (nextUrl) params.set("next", nextUrl);
return redirect(`/vercel/connect?${params.toString()}`);
}
// Route without state but with configurationId: marketplace-invoked flow
if (configurationId) {
const params = new URLSearchParams({ code, configurationId, origin: "marketplace" });
if (nextUrl) params.set("next", nextUrl);
return redirect(`/vercel/onboarding?${params.toString()}`);
}
logger.error("Missing both state and configurationId from Vercel callback");
throw new Response("Missing state or configurationId parameter", { status: 400 });
}
@@ -0,0 +1,52 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { organizationVercelIntegrationPath } from "~/utils/pathBuilder";
const SearchParamsSchema = z.object({
configurationId: z.string(),
});
/**
* Endpoint to handle Vercel integration configuration request coming from marketplace
*/
export const loader = async ({ request }: LoaderFunctionArgs) => {
await requireUserId(request);
const url = new URL(request.url);
const searchParams = Object.fromEntries(url.searchParams);
const { configurationId } = SearchParamsSchema.parse(searchParams);
// Find the organization integration by configurationId (installationId in integrationData)
const integration = await prisma.organizationIntegration.findFirst({
where: {
service: "VERCEL",
deletedAt: null,
integrationData: {
path: ["installationId"],
equals: configurationId,
},
},
include: {
organization: {
select: {
slug: true,
},
},
},
});
if (!integration) {
throw new Response("Integration not found", { status: 404 });
}
// Redirect to the organization's Vercel integration page
return redirect(organizationVercelIntegrationPath(integration.organization));
};
// This route doesn't render anything, it just redirects
export default function VercelConfigurePage() {
return null;
}
+170
View File
@@ -0,0 +1,170 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { fromPromise } from "neverthrow";
import { z } from "zod";
import { prisma } from "~/db.server";
import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercelIntegration.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { v3ProjectSettingsPath } from "~/utils/pathBuilder";
import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
const VercelConnectSchema = z.object({
state: z.string(),
configurationId: z.string().optional(),
code: z.string(),
next: z.string().optional(),
origin: z.enum(["marketplace", "dashboard"]),
});
async function createOrFindVercelIntegration(
organizationId: string,
projectId: string,
tokenResponse: TokenResponse,
configurationId: string | undefined,
origin: 'marketplace' | 'dashboard'
): Promise<void> {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { organization: true },
});
if (!project) {
throw new Error("Project not found");
}
let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId(
organizationId,
tokenResponse.teamId ?? null
);
if (orgIntegration) {
await VercelIntegrationRepository.updateVercelOrgIntegrationToken({
integrationId: orgIntegration.id,
accessToken: tokenResponse.accessToken,
tokenType: tokenResponse.tokenType,
teamId: tokenResponse.teamId ?? null,
userId: tokenResponse.userId,
installationId: configurationId,
raw: tokenResponse.raw
});
} else {
await VercelIntegrationRepository.createVercelOrgIntegration({
accessToken: tokenResponse.accessToken,
tokenType: tokenResponse.tokenType,
teamId: tokenResponse.teamId ?? null,
userId: tokenResponse.userId,
installationId: configurationId,
organization: project.organization,
raw: tokenResponse.raw,
origin,
});
}
}
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const url = requestUrl(request);
const parsed = VercelConnectSchema.safeParse(Object.fromEntries(url.searchParams));
if (!parsed.success) {
logger.error("Invalid Vercel connect params", { error: parsed.error });
throw new Response("Invalid parameters", { status: 400 });
}
const { state, configurationId, code, next, origin } = parsed.data;
const validationResult = await validateVercelOAuthState(state);
if (!validationResult.ok) {
logger.error("Invalid Vercel OAuth state JWT", { error: validationResult.error });
if (
validationResult.error?.includes("expired") ||
validationResult.error?.includes("Token has expired")
) {
const params = new URLSearchParams({ error: "expired" });
return redirect(`/vercel/onboarding?${params.toString()}`);
}
throw new Response("Invalid state", { status: 400 });
}
const stateData = validationResult.state;
const project = await prisma.project.findFirst({
where: {
id: stateData.projectId,
organizationId: stateData.organizationId,
deletedAt: null,
organization: {
members: {
some: { userId },
},
},
},
include: {
organization: true,
},
});
if (!project) {
logger.error("Project not found or access denied", {
projectId: stateData.projectId,
userId,
});
throw new Response("Project not found", { status: 404 });
}
const tokenResult = await VercelIntegrationRepository.exchangeCodeForToken(code);
if (tokenResult.isErr()) {
const params = new URLSearchParams({ error: "expired" });
return redirect(`/vercel/onboarding?${params.toString()}`);
}
const tokenResponse = tokenResult.value;
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: stateData.environmentSlug,
archivedAt: null,
},
});
if (!environment) {
logger.error("Environment not found", {
projectId: project.id,
environmentSlug: stateData.environmentSlug,
});
throw new Response("Environment not found", { status: 404 });
}
const settingsPath = v3ProjectSettingsPath(
{ slug: stateData.organizationSlug },
{ slug: stateData.projectSlug },
{ slug: environment.slug }
);
const result = await fromPromise(
createOrFindVercelIntegration(stateData.organizationId, stateData.projectId, tokenResponse, configurationId, origin),
(error) => error
);
if (result.isErr()) {
logger.error("Failed to complete Vercel integration", { error: result.error });
throw redirect(settingsPath);
}
logger.info("Vercel organization integration created successfully", {
organizationId: stateData.organizationId,
projectId: stateData.projectId,
teamId: tokenResponse.teamId,
});
const params = new URLSearchParams({ vercelOnboarding: "true", origin });
if (next) {
params.set("next", next);
}
return redirect(`${settingsPath}?${params.toString()}`);
}
+73
View File
@@ -0,0 +1,73 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { logger } from "~/services/logger.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
import { findProjectBySlug } from "~/models/project.server";
const QuerySchema = z.object({
org_slug: z.string(),
project_slug: z.string(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const searchParams = new URL(request.url).searchParams;
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
if (!parsed.success) {
logger.warn("Vercel App installation redirect with invalid params", {
searchParams,
error: parsed.error,
});
throw redirect("/");
}
const { org_slug, project_slug } = parsed.data;
const user = await requireUser(request);
// Find the organization
const org = await $replica.organization.findFirst({
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
},
});
if (!org) {
throw redirect("/");
}
// Find the project
const project = await findProjectBySlug(org_slug, project_slug, user.id);
if (!project) {
logger.warn("Vercel App installation attempt for non-existent project", {
org_slug,
project_slug,
userId: user.id,
});
throw redirect("/");
}
// Use "prod" as the default environment slug for the redirect
// The callback will redirect to the settings page for this environment
const environmentSlug = "prod";
// Generate JWT state token
const stateToken = await generateVercelOAuthState({
organizationId: org.id,
projectId: project.id,
environmentSlug,
organizationSlug: org_slug,
projectSlug: project_slug,
});
// Generate Vercel install URL
const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken);
return redirect(vercelInstallUrl);
};
@@ -0,0 +1,465 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json, redirect } from "@remix-run/server-runtime";
import { fromPromise } from "neverthrow";
import { useEffect, useState } from "react";
import { Form, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BuildingOfficeIcon, FolderIcon } from "@heroicons/react/20/solid";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormTitle } from "~/components/primitives/FormTitle";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Select, SelectItem } from "~/components/primitives/Select";
import { ButtonSpinner } from "~/components/primitives/Spinner";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder";
import { redirectWithErrorMessage } from "~/models/message.server";
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
const LoaderParamsSchema = z.object({
organizationId: z.string().optional().nullable(),
code: z.string().optional().nullable(),
configurationId: z.string().optional().nullable(),
next: z.string().optional().nullable(),
error: z.string().optional().nullable(),
});
const SelectOrgActionSchema = z.object({
action: z.literal("select-org"),
organizationId: z.string(),
code: z.string(),
configurationId: z.string().optional().nullable(),
next: z.string().optional(),
});
const SelectProjectActionSchema = z.object({
action: z.literal("select-project"),
projectId: z.string(),
organizationId: z.string(),
code: z.string(),
configurationId: z.string().optional().nullable(),
next: z.string().optional().nullable(),
});
const ActionSchema = z.discriminatedUnion("action", [
SelectOrgActionSchema,
SelectProjectActionSchema,
]);
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const url = new URL(request.url);
const params = LoaderParamsSchema.safeParse({
organizationId: url.searchParams.get("organizationId"),
code: url.searchParams.get("code"),
configurationId: url.searchParams.get("configurationId"),
next: url.searchParams.get("next"),
error: url.searchParams.get("error"),
});
if (!params.success) {
logger.error("Invalid params for Vercel onboarding", { error: params.error });
throw redirectWithErrorMessage(
"/",
request,
"Invalid installation parameters. Please try again from Vercel."
);
}
const { error } = params.data;
if (error === "expired") {
return typedjson({
step: "error" as const,
error: "Your installation session has expired. Please start the installation again.",
code: params.data.code ?? null,
configurationId: params.data.configurationId ?? null,
next: params.data.next ?? null,
});
}
if (!params.data.code) {
logger.error("Missing code parameter for Vercel onboarding");
throw redirectWithErrorMessage(
"/",
request,
"Invalid installation parameters. Please try again from Vercel."
);
}
const code = params.data.code;
const organizations = await prisma.organization.findMany({
where: {
members: {
some: { userId },
},
deletedAt: null,
},
select: {
id: true,
title: true,
slug: true,
projects: {
where: {
deletedAt: null,
},
select: {
id: true,
name: true,
slug: true,
},
orderBy: {
createdAt: "asc",
},
},
},
orderBy: {
createdAt: "asc",
},
});
// New user: no organizations
if (organizations.length === 0) {
const onboardingParams = new URLSearchParams();
onboardingParams.set("code", code);
if (params.data.configurationId) {
onboardingParams.set("configurationId", params.data.configurationId);
}
onboardingParams.set("integration", "vercel");
if (params.data.next) {
onboardingParams.set("next", params.data.next);
}
throw redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`);
}
// If organizationId is provided, show project selection
if (params.data.organizationId) {
const organization = organizations.find((org) => org.id === params.data.organizationId);
if (!organization) {
logger.error("Organization not found or access denied", {
organizationId: params.data.organizationId,
userId,
});
throw redirectWithErrorMessage(
"/",
request,
"Organization not found. Please try again."
);
}
return typedjson({
step: "project" as const,
organization,
organizations,
code: code,
configurationId: params.data.configurationId ?? null,
next: params.data.next ?? null,
});
}
return typedjson({
step: "org" as const,
organizations,
code: code,
configurationId: params.data.configurationId ?? null,
next: params.data.next ?? null,
});
}
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const submission = ActionSchema.safeParse({
action: formData.get("action"),
organizationId: formData.get("organizationId"),
projectId: formData.get("projectId"),
code: formData.get("code"),
configurationId: formData.get("configurationId"),
next: formData.get("next"),
});
if (!submission.success) {
return json({ error: "Invalid submission" }, { status: 400 });
}
const { code, configurationId, next } = submission.data;
// Handle org selection
if (submission.data.action === "select-org") {
const { organizationId } = submission.data;
const projectParams = new URLSearchParams();
projectParams.set("organizationId", organizationId);
projectParams.set("code", code);
if (configurationId) {
projectParams.set("configurationId", configurationId);
}
if (next) {
projectParams.set("next", next);
}
return redirect(`/vercel/onboarding?${projectParams.toString()}`);
}
// Handle project selection
const { projectId, organizationId } = submission.data;
const project = await prisma.project.findFirst({
where: {
id: projectId,
organizationId,
deletedAt: null,
organization: {
members: { some: { userId } },
},
},
include: {
organization: true,
},
});
if (!project) {
logger.error("Project not found or access denied", { projectId, userId });
return json({ error: "Project not found" }, { status: 404 });
}
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: "prod",
archivedAt: null,
},
});
if (!environment) {
logger.error("Environment not found", { projectId: project.id });
return json({ error: "Environment not found" }, { status: 404 });
}
const stateResult = await fromPromise(
generateVercelOAuthState({
organizationId: project.organizationId,
projectId: project.id,
environmentSlug: environment.slug,
organizationSlug: project.organization.slug,
projectSlug: project.slug,
}),
(error) => error
);
if (stateResult.isErr()) {
logger.error("Failed to generate Vercel OAuth state", { error: stateResult.error });
return json({ error: "Failed to generate installation state" }, { status: 500 });
}
const params = new URLSearchParams();
params.set("state", stateResult.value);
params.set("code", code);
if (configurationId) {
params.set("configurationId", configurationId);
}
params.set("origin", "marketplace");
if (next) {
params.set("next", next);
}
return redirect(`/vercel/connect?${params.toString()}`, 303);
}
export default function VercelOnboardingPage() {
const data = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const [isInstalling, setIsInstalling] = useState(false);
// Reset isInstalling when navigation returns to idle (e.g. on error)
useEffect(() => {
if (navigation.state === "idle" && isInstalling) {
setIsInstalling(false);
}
}, [navigation.state, isInstalling]);
if (data.step === "error") {
return (
<AppContainer className="bg-charcoal-900">
<BackgroundWrapper>
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<FormTitle title="Installation Expired" description={data.error} />
<Button
variant="primary/medium"
onClick={() => window.close()}
className="w-full"
>
Close
</Button>
</MainCenteredContainer>
</BackgroundWrapper>
</AppContainer>
);
}
if (data.step === "org") {
const newOrgUrl = (() => {
const params = new URLSearchParams();
params.set("code", data.code);
if (data.configurationId) {
params.set("configurationId", data.configurationId);
}
params.set("integration", "vercel");
if (data.next) {
params.set("next", data.next);
}
return `/orgs/new?${params.toString()}`;
})();
return (
<AppContainer className="bg-charcoal-900">
<BackgroundWrapper>
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<FormTitle
LeadingIcon={<BuildingOfficeIcon className="size-7 text-indigo-500" />}
title="Select Organization"
description="Choose which organization to install the Vercel integration into."
/>
<Form method="post">
<input type="hidden" name="action" value="select-org" />
<input type="hidden" name="code" value={data.code} />
{data.configurationId && (
<input type="hidden" name="configurationId" value={data.configurationId} />
)}
{data.next && <input type="hidden" name="next" value={data.next} />}
<Fieldset>
<InputGroup>
<Label>Organization</Label>
<Select
name="organizationId"
placeholder="Choose an organization"
required
variant="tertiary/medium"
dropdownIcon
defaultValue={data.organizations[0]?.id}
text={(v) =>
typeof v === "string"
? data.organizations.find((o) => o.id === v)?.title || "Choose an organization"
: "Choose an organization"
}
>
{data.organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.title}
</SelectItem>
))}
</Select>
</InputGroup>
<FormButtons
confirmButton={
<div className="flex items-center gap-2">
<LinkButton to={newOrgUrl} variant="tertiary/small">
+ New Organization
</LinkButton>
<Button type="submit" variant="primary/small">
Continue
</Button>
</div>
}
/>
</Fieldset>
</Form>
</MainCenteredContainer>
</BackgroundWrapper>
</AppContainer>
);
}
const newProjectUrl = (() => {
const params = new URLSearchParams();
params.set("code", data.code);
if (data.configurationId) {
params.set("configurationId", data.configurationId);
}
params.set("integration", "vercel");
params.set("organizationId", data.organization.id);
if (data.next) {
params.set("next", data.next);
}
return `${newProjectPath({ slug: data.organization.slug })}?${params.toString()}`;
})();
const isLoading = isSubmitting || isInstalling;
return (
<AppContainer className="bg-charcoal-900">
<BackgroundWrapper>
<MainCenteredContainer className="max-w-[26rem] rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<FormTitle
LeadingIcon={<FolderIcon className="size-7 text-indigo-500" />}
title="Select Project"
description={`Choose which project in "${data.organization.title}" to install the Vercel integration into.`}
/>
<Form method="post" onSubmit={() => setIsInstalling(true)}>
<input type="hidden" name="action" value="select-project" />
<input type="hidden" name="organizationId" value={data.organization.id} />
<input type="hidden" name="code" value={data.code} />
{data.configurationId && (
<input type="hidden" name="configurationId" value={data.configurationId} />
)}
{data.next && <input type="hidden" name="next" value={data.next} />}
<Fieldset>
<InputGroup>
<Label>Project</Label>
<Select
name="projectId"
placeholder="Choose a project"
required
variant="tertiary/medium"
dropdownIcon
defaultValue={data.organization.projects[0]?.id}
text={(v) =>
typeof v === "string"
? data.organization.projects.find((p) => p.id === v)?.name || "Choose a project"
: "Choose a project"
}
>
{data.organization.projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</Select>
</InputGroup>
<FormButtons
confirmButton={
<div className="flex items-center gap-2">
<LinkButton to={newProjectUrl} variant="tertiary/small" disabled={isLoading}>
+ New Project
</LinkButton>
<Button
type="submit"
variant="primary/small"
disabled={isLoading}
TrailingIcon={isLoading ? ButtonSpinner : undefined}
>
{isLoading ? "Continuing…" : "Continue"}
</Button>
</div>
}
/>
</Fieldset>
</Form>
</MainCenteredContainer>
</BackgroundWrapper>
</AppContainer>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { prisma } from "~/db.server";
import { requireUserId } from "./session.server";
export async function requireOrganization(request: Request, organizationSlug: string) {
const userId = await requireUserId(request);
const organization = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
deletedAt: null,
},
});
if (!organization) {
throw new Response("Organization not found", { status: 404 });
}
return { organization, userId };
}
+4 -1
View File
@@ -10,5 +10,8 @@ export async function postAuthentication({
loginMethod: User["authenticationMethod"];
isNewUser: boolean;
}) {
telemetry.user.identify({ user, isNewUser });
telemetry.user.identify({
user,
isNewUser,
});
}
@@ -0,0 +1,53 @@
import { createCookie } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { telemetry } from "~/services/telemetry.server";
const ReferralSourceSchema = z.enum(["vercel"]);
export type ReferralSource = z.infer<typeof ReferralSourceSchema>;
// Cookie that persists for 1 hour to track referral source during login flow
export const referralSourceCookie = createCookie("referral-source", {
maxAge: 60 * 60, // 1 hour
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
});
export async function getReferralSource(request: Request): Promise<ReferralSource | null> {
const cookie = request.headers.get("Cookie");
const value = await referralSourceCookie.parse(cookie);
const parsed = ReferralSourceSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
export async function setReferralSourceCookie(source: ReferralSource): Promise<string> {
return referralSourceCookie.serialize(source);
}
export async function clearReferralSourceCookie(): Promise<string> {
return referralSourceCookie.serialize("", {
maxAge: 0,
});
}
export async function trackAndClearReferralSource(
request: Request,
userId: string,
headers: Headers
): Promise<void> {
const referralSource = await getReferralSource(request);
if (!referralSource) return;
headers.append("Set-Cookie", await clearReferralSourceCookie());
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return;
const userAge = Date.now() - user.createdAt.getTime();
if (userAge >= 30 * 1000) return;
telemetry.user.identify({ user, isNewUser: true, referralSource });
}
+23 -9
View File
@@ -28,18 +28,32 @@ class Telemetry {
}
user = {
identify: ({ user, isNewUser }: { user: User; isNewUser: boolean }) => {
identify: ({
user,
isNewUser,
referralSource,
}: {
user: User;
isNewUser: boolean;
referralSource?: string;
}) => {
if (this.#posthogClient) {
const properties: Record<string, any> = {
email: user.email,
name: user.name,
authenticationMethod: user.authenticationMethod,
admin: user.admin,
createdAt: user.createdAt,
isNewUser,
};
if (referralSource) {
properties.referralSource = referralSource;
}
this.#posthogClient.identify({
distinctId: user.id,
properties: {
email: user.email,
name: user.name,
authenticationMethod: user.authenticationMethod,
admin: user.admin,
createdAt: user.createdAt,
isNewUser,
},
properties,
});
}
if (isNewUser) {
@@ -0,0 +1,656 @@
import type {
PrismaClient,
OrganizationProjectIntegration,
OrganizationIntegration,
SecretReference,
} from "@trigger.dev/database";
import { ResultAsync } from "neverthrow";
import { prisma, $transaction } from "~/db.server";
import { logger } from "~/services/logger.server";
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import {
VercelProjectIntegrationDataSchema,
VercelProjectIntegrationData,
VercelIntegrationConfig,
SyncEnvVarsMapping,
TriggerEnvironmentType,
EnvSlug,
envTypeToSlug,
createDefaultVercelIntegrationData,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
export type VercelProjectIntegrationWithParsedData = OrganizationProjectIntegration & {
parsedIntegrationData: VercelProjectIntegrationData;
};
export type VercelProjectIntegrationWithData = VercelProjectIntegrationWithParsedData & {
organizationIntegration: OrganizationIntegration;
};
export type VercelProjectIntegrationWithProject = VercelProjectIntegrationWithData & {
project: {
id: string;
name: string;
slug: string;
};
};
export class VercelIntegrationService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async getVercelProjectIntegration(
projectId: string,
): Promise<VercelProjectIntegrationWithData | null> {
const integration = await this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
include: {
organizationIntegration: true,
},
});
if (!integration) {
return null;
}
const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData);
if (!parsedData.success) {
logger.error("Failed to parse Vercel integration data", {
projectId,
integrationId: integration.id,
error: parsedData.error,
});
return null;
}
return {
...integration,
parsedIntegrationData: parsedData.data,
};
}
async getConnectedVercelProjects(
organizationId: string
): Promise<VercelProjectIntegrationWithProject[]> {
const integrations = await this.#prismaClient.organizationProjectIntegration.findMany({
where: {
deletedAt: null,
organizationIntegration: {
organizationId,
service: "VERCEL",
deletedAt: null,
},
},
include: {
organizationIntegration: true,
project: {
select: {
id: true,
name: true,
slug: true,
},
},
},
});
return integrations
.map((integration) => {
const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData);
if (!parsedData.success) {
logger.error("Failed to parse Vercel integration data", {
integrationId: integration.id,
error: parsedData.error,
});
return null;
}
return {
...integration,
parsedIntegrationData: parsedData.data,
};
})
.filter((i): i is VercelProjectIntegrationWithProject => i !== null);
}
async createVercelProjectIntegration(params: {
organizationIntegrationId: string;
projectId: string;
vercelProjectId: string;
vercelProjectName: string;
vercelTeamId: string | null;
vercelTeamSlug?: string;
installedByUserId?: string;
}): Promise<OrganizationProjectIntegration> {
const integrationData = createDefaultVercelIntegrationData(
params.vercelProjectId,
params.vercelProjectName,
params.vercelTeamId,
params.vercelTeamSlug
);
return this.#prismaClient.organizationProjectIntegration.create({
data: {
organizationIntegrationId: params.organizationIntegrationId,
projectId: params.projectId,
externalEntityId: params.vercelProjectId,
integrationData: integrationData,
installedBy: params.installedByUserId,
},
});
}
async selectVercelProject(params: {
organizationId: string;
projectId: string;
vercelProjectId: string;
vercelProjectName: string;
userId: string;
}): Promise<{
integration: OrganizationProjectIntegration;
syncResult: { success: boolean; errors: string[] };
}> {
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByOrganization(
params.organizationId
);
if (!orgIntegration) {
throw new Error("No Vercel organization integration found");
}
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
const vercelTeamSlug = await VercelIntegrationRepository.getVercelClient(orgIntegration)
.andThen((client) => VercelIntegrationRepository.getTeamSlug(client, teamId))
.match(
(slug) => slug,
() => undefined
);
// Use a serializable transaction to prevent duplicate project integrations
// from concurrent selectVercelProject calls (read-then-write race condition).
const txResult = await $transaction(
this.#prismaClient,
"selectVercelProject",
async (tx) => {
const existing = await tx.organizationProjectIntegration.findFirst({
where: {
projectId: params.projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
include: {
organizationIntegration: true,
},
});
if (existing) {
const parsedData = VercelProjectIntegrationDataSchema.safeParse(
existing.integrationData
);
const updated = await tx.organizationProjectIntegration.update({
where: { id: existing.id },
data: {
externalEntityId: params.vercelProjectId,
integrationData: {
...(parsedData.success ? parsedData.data : {}),
vercelProjectId: params.vercelProjectId,
vercelProjectName: params.vercelProjectName,
vercelTeamId: teamId,
vercelTeamSlug,
},
},
});
return {
integration: updated,
wasCreated: false,
vercelStagingEnvironment: parsedData.success
? parsedData.data.config.vercelStagingEnvironment
: null,
};
}
const integrationData = createDefaultVercelIntegrationData(
params.vercelProjectId,
params.vercelProjectName,
teamId,
vercelTeamSlug
);
const created = await tx.organizationProjectIntegration.create({
data: {
organizationIntegrationId: orgIntegration.id,
projectId: params.projectId,
externalEntityId: params.vercelProjectId,
integrationData: integrationData,
installedBy: params.userId,
},
});
return {
integration: created,
wasCreated: true,
vercelStagingEnvironment: null,
};
},
{ isolationLevel: "Serializable" }
);
if (!txResult) {
throw new Error("Failed to select Vercel project: transaction returned undefined");
}
const { integration, wasCreated, vercelStagingEnvironment } = txResult;
const syncResultAsync = await VercelIntegrationRepository.syncApiKeysToVercel({
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
teamId,
vercelStagingEnvironment,
orgIntegration,
});
const syncResult = syncResultAsync.isOk()
? { success: syncResultAsync.value.errors.length === 0, errors: syncResultAsync.value.errors }
: { success: false, errors: [syncResultAsync.error.message] };
if (wasCreated) {
const disableResult = await VercelIntegrationRepository.getVercelClient(orgIntegration)
.andThen((client) =>
VercelIntegrationRepository.disableAutoAssignCustomDomains(
client,
params.vercelProjectId,
teamId
)
);
if (disableResult.isErr()) {
logger.warn("Failed to disable autoAssignCustomDomains during project selection", {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
error: disableResult.error.message,
});
}
logger.info("Vercel project selected and API keys synced", {
projectId: params.projectId,
vercelProjectId: params.vercelProjectId,
vercelProjectName: params.vercelProjectName,
syncSuccess: syncResult.success,
syncErrors: syncResult.errors,
});
}
return { integration, syncResult };
}
async updateVercelIntegrationConfig(
projectId: string,
configUpdates: Partial<VercelIntegrationConfig>
): Promise<VercelProjectIntegrationWithParsedData | null> {
const existing = await this.getVercelProjectIntegration(projectId);
if (!existing) {
return null;
}
const updatedConfig = {
...existing.parsedIntegrationData.config,
...configUpdates,
};
const updatedData: VercelProjectIntegrationData = {
...existing.parsedIntegrationData,
config: updatedConfig,
};
const updated = await this.#prismaClient.organizationProjectIntegration.update({
where: { id: existing.id },
data: {
integrationData: updatedData,
},
});
if (!updatedConfig.atomicBuilds?.includes("prod")) {
return { ...updated, parsedIntegrationData: updatedData };
}
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(
projectId
);
if (orgIntegration) {
await this.#syncTriggerVersionToVercelProduction(
projectId,
updatedConfig.atomicBuilds,
orgIntegration
);
}
return {
...updated,
parsedIntegrationData: updatedData,
};
}
async updateSyncEnvVarsMapping(
projectId: string,
syncEnvVarsMapping: SyncEnvVarsMapping
): Promise<VercelProjectIntegrationWithParsedData | null> {
const existing = await this.getVercelProjectIntegration(projectId);
if (!existing) {
return null;
}
const updatedData: VercelProjectIntegrationData = {
...existing.parsedIntegrationData,
syncEnvVarsMapping,
};
const updated = await this.#prismaClient.organizationProjectIntegration.update({
where: { id: existing.id },
data: {
integrationData: updatedData,
},
});
return {
...updated,
parsedIntegrationData: updatedData,
};
}
async updateSyncEnvVarForEnvironment(
projectId: string,
envVarKey: string,
environmentType: TriggerEnvironmentType,
syncEnabled: boolean
): Promise<VercelProjectIntegrationWithParsedData | null> {
const existing = await this.getVercelProjectIntegration(projectId);
if (!existing) {
return null;
}
const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {};
const envSlug = envTypeToSlug(environmentType);
const currentEnvSettings = currentMapping[envSlug] || {};
const updatedMapping: SyncEnvVarsMapping = {
...currentMapping,
[envSlug]: {
...currentEnvSettings,
[envVarKey]: syncEnabled,
},
};
const updatedData: VercelProjectIntegrationData = {
...existing.parsedIntegrationData,
syncEnvVarsMapping: updatedMapping,
};
const updated = await this.#prismaClient.organizationProjectIntegration.update({
where: { id: existing.id },
data: {
integrationData: updatedData,
},
});
return {
...updated,
parsedIntegrationData: updatedData,
};
}
async removeSyncEnvVarForEnvironment(
projectId: string,
envVarKey: string,
environmentType: TriggerEnvironmentType
): Promise<void> {
const existing = await this.getVercelProjectIntegration(projectId);
if (!existing) return;
const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {};
const envSlug = envTypeToSlug(environmentType);
const currentEnvSettings = currentMapping[envSlug];
if (!currentEnvSettings || !(envVarKey in currentEnvSettings)) return;
const { [envVarKey]: _, ...rest } = currentEnvSettings;
const updatedMapping = { ...currentMapping, [envSlug]: rest };
await this.#prismaClient.organizationProjectIntegration.update({
where: { id: existing.id },
data: {
integrationData: {
...existing.parsedIntegrationData,
syncEnvVarsMapping: updatedMapping,
},
},
});
}
async completeOnboarding(
projectId: string,
params: {
vercelStagingEnvironment?: { environmentId: string; displayName: string } | null;
pullEnvVarsBeforeBuild?: EnvSlug[] | null;
atomicBuilds?: EnvSlug[] | null;
discoverEnvVars?: EnvSlug[] | null;
syncEnvVarsMapping?: SyncEnvVarsMapping;
}
): Promise<VercelProjectIntegrationWithParsedData | null> {
const existing = await this.getVercelProjectIntegration(projectId);
if (!existing) {
return null;
}
const syncEnvVarsMapping = params.syncEnvVarsMapping ?? { "dev":{}, "stg":{}, "prod":{}, "preview":{} };
const updatedData: VercelProjectIntegrationData = {
...existing.parsedIntegrationData,
config: {
...existing.parsedIntegrationData.config,
pullEnvVarsBeforeBuild: params.pullEnvVarsBeforeBuild ?? null,
atomicBuilds: params.atomicBuilds ?? null,
discoverEnvVars: params.discoverEnvVars ?? null,
vercelStagingEnvironment: params.vercelStagingEnvironment ?? null,
},
//This is intentionally not updated here, in case of resetting the onboarding it should not override the existing mapping with an empty one
syncEnvVarsMapping: existing.parsedIntegrationData.syncEnvVarsMapping,
onboardingCompleted: true,
};
const updated = await this.#prismaClient.organizationProjectIntegration.update({
where: { id: existing.id },
data: {
integrationData: updatedData,
},
});
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject(
projectId
);
if (orgIntegration) {
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
const pullResult = await VercelIntegrationRepository.pullEnvVarsFromVercel({
projectId,
vercelProjectId: updatedData.vercelProjectId,
teamId,
vercelStagingEnvironment: params.vercelStagingEnvironment,
syncEnvVarsMapping,
orgIntegration,
});
if (pullResult.isErr()) {
logger.error("Failed to pull env vars from Vercel during onboarding", {
projectId,
error: pullResult.error.message,
});
} else if (pullResult.value.errors.length > 0) {
logger.warn("Errors pulling env vars from Vercel during onboarding", {
projectId,
errors: pullResult.value.errors,
});
}
await this.#syncTriggerVersionToVercelProduction(
projectId,
updatedData.config.atomicBuilds,
orgIntegration
);
}
return {
...updated,
parsedIntegrationData: updatedData,
};
}
async #syncTriggerVersionToVercelProduction(
projectId: string,
atomicBuilds: string[] | null | undefined,
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }
): Promise<void> {
if (!atomicBuilds?.includes("prod")) {
return;
}
const prodEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
where: {
projectId,
type: "PRODUCTION",
},
select: {
id: true,
},
});
if (!prodEnvironment) {
return;
}
const currentDeployment = await findCurrentWorkerDeployment({
environmentId: prodEnvironment.id,
});
if (!currentDeployment?.version) {
return;
}
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
if (clientResult.isErr()) {
logger.error("Failed to get Vercel client for TRIGGER_VERSION sync", {
projectId,
error: clientResult.error.message,
});
return;
}
const client = clientResult.value;
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
// Get the Vercel project ID from the project integration
const projectIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId,
organizationIntegrationId: orgIntegration.id,
deletedAt: null,
},
select: {
externalEntityId: true,
},
});
if (!projectIntegration) {
return;
}
const vercelProjectId = projectIntegration.externalEntityId;
// Check if TRIGGER_VERSION already exists targeting production
const envVarsResult = await VercelIntegrationRepository.getVercelEnvironmentVariables(
client,
vercelProjectId,
teamId
);
if (envVarsResult.isErr()) {
logger.warn("Failed to fetch Vercel env vars for TRIGGER_VERSION sync", {
projectId,
vercelProjectId,
error: envVarsResult.error.message,
});
return;
}
const existingTriggerVersion = envVarsResult.value.find(
(env) => env.key === "TRIGGER_VERSION" && env.target.includes("production")
);
if (existingTriggerVersion) {
return;
}
// Push TRIGGER_VERSION to Vercel production
const createResult = await ResultAsync.fromPromise(
client.projects.createProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
upsert: "true",
requestBody: {
key: "TRIGGER_VERSION",
value: currentDeployment.version,
target: ["production"] as any,
type: "encrypted",
},
}),
(error) => error
);
if (createResult.isErr()) {
logger.error("Failed to sync TRIGGER_VERSION to Vercel production", {
projectId,
vercelProjectId,
error: createResult.error instanceof Error ? createResult.error.message : String(createResult.error),
});
return;
}
logger.info("Synced TRIGGER_VERSION to Vercel production", {
projectId,
vercelProjectId,
version: currentDeployment.version,
});
}
async disconnectVercelProject(projectId: string): Promise<boolean> {
const existing = await this.getVercelProjectIntegration(projectId);
if (!existing) {
return false;
}
await this.#prismaClient.organizationProjectIntegration.update({
where: { id: existing.id },
data: {
deletedAt: new Date(),
},
});
return true;
}
}
+24
View File
@@ -121,6 +121,14 @@ export function organizationSettingsPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings`;
}
export function organizationIntegrationsPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/integrations`;
}
export function organizationVercelIntegrationPath(organization: OrgForPath) {
return `${organizationIntegrationsPath(organization)}/vercel`;
}
function organizationParam(organization: OrgForPath) {
return organization.slug;
}
@@ -151,6 +159,22 @@ export function githubAppInstallPath(organizationSlug: string, redirectTo: strin
)}`;
}
export function vercelAppInstallPath(organizationSlug: string, projectSlug: string) {
return `/vercel/install?org_slug=${organizationSlug}&project_slug=${projectSlug}`;
}
export function vercelCallbackPath() {
return `/vercel/callback`;
}
export function vercelResourcePath(
organizationSlug: string,
projectSlug: string,
environmentSlug: string
) {
return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/vercel`;
}
export function v3EnvironmentPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -6,9 +6,12 @@ import { env } from "~/env.server";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import {
type CreateEnvironmentVariables,
type CreateResult,
type DeleteEnvironmentVariable,
type DeleteEnvironmentVariableValue,
type EditEnvironmentVariable,
type EditEnvironmentVariableValue,
type EnvironmentVariable,
type EnvironmentVariableWithSecret,
type ProjectEnvironmentVariable,
@@ -45,18 +48,7 @@ const SecretValue = z.object({ secret: z.string() });
export class EnvironmentVariablesRepository implements Repository {
constructor(private prismaClient: PrismaClient = prisma) {}
async create(
projectId: string,
options: {
override: boolean;
environmentIds: string[];
isSecret?: boolean;
variables: {
key: string;
value: string;
}[];
}
): Promise<CreateResult> {
async create(projectId: string, options: CreateEnvironmentVariables): Promise<CreateResult> {
const project = await this.prismaClient.project.findFirst({
where: {
id: projectId,
@@ -164,10 +156,49 @@ export class EnvironmentVariablesRepository implements Repository {
prismaClient: tx,
});
// If parentEnvironmentId is provided and isSecret is not explicitly set,
// look up if the parent has this variable marked as secret
let inheritedIsSecret: boolean | undefined = undefined;
if (options.isSecret === undefined && options.parentEnvironmentId) {
const parentVariableValue = await tx.environmentVariableValue.findFirst({
where: {
variableId: environmentVariable.id,
environmentId: options.parentEnvironmentId,
},
select: {
isSecret: true,
},
});
if (parentVariableValue?.isSecret) {
inheritedIsSecret = true;
}
}
const effectiveIsSecret = options.isSecret ?? inheritedIsSecret;
//set the secret values and references
for (const environmentId of options.environmentIds) {
const key = secretKey(projectId, environmentId, variable.key);
const existingValueRecord = await tx.environmentVariableValue.findFirst({
where: {
variableId: environmentVariable.id,
environmentId,
},
});
// Check if value already exists and is the same, and no metadata change (e.g. isSecret toggle)
const existingSecret = await secretStore.getSecret(SecretValue, key);
const canSkip =
existingSecret &&
existingSecret.secret === variable.value &&
existingValueRecord &&
(options.isSecret === undefined ||
existingValueRecord.isSecret === options.isSecret);
if (canSkip) {
continue;
}
//create the secret reference
const secretReference = await tx.secretReference.upsert({
where: {
@@ -180,23 +211,36 @@ export class EnvironmentVariablesRepository implements Repository {
update: {},
});
const variableValue = await tx.environmentVariableValue.upsert({
where: {
variableId_environmentId: {
variableId: environmentVariable.id,
environmentId,
if (existingValueRecord) {
await tx.environmentVariableValue.update({
where: {
id: existingValueRecord.id,
},
},
create: {
variableId: environmentVariable.id,
environmentId: environmentId,
valueReferenceId: secretReference.id,
isSecret: options.isSecret,
},
update: {
isSecret: options.isSecret,
},
});
data: {
version: {
increment: 1,
},
...(options.lastUpdatedBy ? { lastUpdatedBy: options.lastUpdatedBy } : {}),
valueReferenceId: secretReference.id,
...(options.isSecret !== undefined
? {
isSecret: options.isSecret,
}
: {}),
},
});
} else {
await tx.environmentVariableValue.create({
data: {
variableId: environmentVariable.id,
environmentId: environmentId,
valueReferenceId: secretReference.id,
isSecret: effectiveIsSecret,
version: 1,
lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : Prisma.JsonNull,
},
});
}
await secretStore.setSecret<{ secret: string }>(key, {
secret: variable.value,
@@ -226,14 +270,7 @@ export class EnvironmentVariablesRepository implements Repository {
}
}
async edit(
projectId: string,
options: {
values: { value: string; environmentId: string }[];
id: string;
keepEmptyValues?: boolean;
}
): Promise<Result> {
async edit(projectId: string, options: EditEnvironmentVariable): Promise<Result> {
const project = await this.prismaClient.project.findFirst({
where: {
id: projectId,
@@ -323,6 +360,20 @@ export class EnvironmentVariablesRepository implements Repository {
await secretStore.setSecret<{ secret: string }>(key, {
secret: value.value,
});
await tx.environmentVariableValue.update({
where: {
variableId_environmentId: {
variableId: environmentVariable.id,
environmentId: value.environmentId,
},
},
data: {
version: {
increment: 1,
},
lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined,
},
});
}
continue;
}
@@ -340,6 +391,8 @@ export class EnvironmentVariablesRepository implements Repository {
variableId: environmentVariable.id,
environmentId: value.environmentId,
valueReferenceId: secretReference.id,
version: 1,
lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : Prisma.JsonNull,
},
});
@@ -360,14 +413,7 @@ export class EnvironmentVariablesRepository implements Repository {
}
}
async editValue(
projectId: string,
options: {
id: string;
environmentId: string;
value: string;
}
): Promise<Result> {
async editValue(projectId: string, options: EditEnvironmentVariableValue): Promise<Result> {
const project = await this.prismaClient.project.findFirst({
where: {
id: projectId,
@@ -426,6 +472,21 @@ export class EnvironmentVariablesRepository implements Repository {
await secretStore.setSecret<{ secret: string }>(key, {
secret: options.value,
});
await tx.environmentVariableValue.update({
where: {
variableId_environmentId: {
variableId: environmentVariable.id,
environmentId: options.environmentId,
},
},
data: {
version: {
increment: 1,
},
lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined,
},
});
});
return {
@@ -6,9 +6,25 @@ export const EnvironmentVariableKey = z
.nonempty("Key is required")
.regex(/^\w+$/, "Keys can only use alphanumeric characters and underscores");
export const EnvironmentVariableUpdaterSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("user"),
userId: z.string(),
}),
z.object({
type: z.literal("integration"),
integration: z.string(),
}),
]);
export type EnvironmentVariableUpdater = z.infer<typeof EnvironmentVariableUpdaterSchema>;
export const CreateEnvironmentVariables = z.object({
override: z.boolean(),
environmentIds: z.array(z.string()),
isSecret: z.boolean().optional(),
parentEnvironmentId: z.string().optional(),
variables: z.array(z.object({ key: EnvironmentVariableKey, value: z.string() })),
lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(),
});
export type CreateEnvironmentVariables = z.infer<typeof CreateEnvironmentVariables>;
@@ -32,6 +48,7 @@ export const EditEnvironmentVariable = z.object({
})
),
keepEmptyValues: z.boolean().optional(),
lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(),
});
export type EditEnvironmentVariable = z.infer<typeof EditEnvironmentVariable>;
@@ -51,6 +68,7 @@ export const EditEnvironmentVariableValue = z.object({
id: z.string(),
environmentId: z.string(),
value: z.string(),
lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(),
});
export type EditEnvironmentVariableValue = z.infer<typeof EditEnvironmentVariableValue>;
@@ -22,6 +22,7 @@ import { environmentTitle } from "~/components/environments/EnvironmentLabel";
import { type Prisma, type prisma, type PrismaClientOrTransaction } from "~/db.server";
import { env } from "~/env.server";
import {
isIntegrationForService,
type OrganizationIntegrationForService,
OrgIntegrationRepository,
} from "~/models/orgIntegration.server";
@@ -644,7 +645,7 @@ export class DeliverAlertService extends BaseService {
},
});
if (!integration) {
if (!integration || !isIntegrationForService(integration, "SLACK")) {
logger.error("[DeliverAlert] Slack integration not found", {
alert,
});
@@ -221,6 +221,7 @@ export class InitializeDeploymentService extends BaseService {
imageReference: imageRef,
imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
git: payload.gitMeta ?? undefined,
commitSHA: payload.gitMeta?.commitSha ?? undefined,
runtime: payload.runtime ?? undefined,
triggeredVia: payload.triggeredVia ?? undefined,
startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
+17
View File
@@ -0,0 +1,17 @@
export * from "./vercelProjectIntegrationSchema";
export function getVercelInstallParams(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const configurationId = url.searchParams.get("configurationId");
const integration = url.searchParams.get("integration");
const next = url.searchParams.get("next");
if (code && configurationId && (integration === "vercel" || !integration)) {
return { code, configurationId, next };
}
return null;
}
@@ -0,0 +1,40 @@
import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt";
import { z } from "zod";
import { env } from "~/env.server";
export const VercelOAuthStateSchema = z.object({
organizationId: z.string(),
projectId: z.string(),
environmentSlug: z.string(),
organizationSlug: z.string(),
projectSlug: z.string(),
});
export type VercelOAuthState = z.infer<typeof VercelOAuthStateSchema>;
export async function generateVercelOAuthState(
params: VercelOAuthState
): Promise<string> {
return generateJWT({
secretKey: env.ENCRYPTION_KEY,
payload: params,
expirationTime: "15m",
});
}
export async function validateVercelOAuthState(
token: string
): Promise<{ ok: true; state: VercelOAuthState } | { ok: false; error: string }> {
const result = await validateJWT(token, env.ENCRYPTION_KEY);
if (!result.ok) {
return { ok: false, error: result.error };
}
const parseResult = VercelOAuthStateSchema.safeParse(result.payload);
if (!parseResult.success) {
return { ok: false, error: "Invalid state payload" };
}
return { ok: true, state: parseResult.data };
}
@@ -0,0 +1,225 @@
import { Result } from "neverthrow";
import { z } from "zod";
export const EnvSlugSchema = z.enum(["dev", "stg", "prod", "preview"]);
export type EnvSlug = z.infer<typeof EnvSlugSchema>;
export const ALL_ENV_SLUGS: EnvSlug[] = ["dev", "stg", "prod", "preview"];
const safeJsonParse = Result.fromThrowable(
(val: string) => JSON.parse(val) as unknown,
() => null
);
/**
* Zod transform for form fields that submit JSON-encoded arrays.
* Parses the string as JSON and returns the array, or null if invalid.
*/
export const jsonArrayField = z.string().optional().transform((val) => {
if (!val) return null;
return safeJsonParse(val).match(
(parsed) => (Array.isArray(parsed) ? parsed : null),
() => null
);
});
/**
* Zod transform for form fields that submit JSON-encoded EnvSlug arrays.
* Parses the string as JSON and validates each element is a valid EnvSlug.
* Invalid elements are filtered out rather than rejecting the whole array.
*/
export const envSlugArrayField = z.string().optional().transform((val): EnvSlug[] | null => {
if (!val) return null;
return safeJsonParse(val).match(
(parsed) => {
if (!Array.isArray(parsed)) return null;
return parsed.filter((item): item is EnvSlug => EnvSlugSchema.safeParse(item).success);
},
() => null
);
});
export const VercelIntegrationConfigSchema = z.object({
atomicBuilds: z.array(EnvSlugSchema).nullable().optional(),
pullEnvVarsBeforeBuild: z.array(EnvSlugSchema).nullable().optional(),
/** Maps a custom Vercel environment to Trigger.dev's staging environment. */
vercelStagingEnvironment: z.object({
environmentId: z.string(),
displayName: z.string(),
}).nullable().optional(),
discoverEnvVars: z.array(EnvSlugSchema).nullable().optional(),
});
export type VercelIntegrationConfig = z.infer<typeof VercelIntegrationConfigSchema>;
export const TriggerEnvironmentType = z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]);
export type TriggerEnvironmentType = z.infer<typeof TriggerEnvironmentType>;
/**
* Per-environment, per-variable sync settings.
* Missing env slug = sync all vars. Missing var in env = sync by default.
* Only explicitly `false` entries disable sync.
*/
export const SyncEnvVarsMappingSchema = z.record(EnvSlugSchema, z.record(z.string(), z.boolean())).default({});
export type SyncEnvVarsMapping = z.infer<typeof SyncEnvVarsMappingSchema>;
export const VercelProjectIntegrationDataSchema = z.object({
config: VercelIntegrationConfigSchema,
syncEnvVarsMapping: SyncEnvVarsMappingSchema,
vercelProjectName: z.string(),
vercelTeamId: z.string().nullable(),
vercelTeamSlug: z.string().optional(),
vercelProjectId: z.string(),
onboardingCompleted: z.boolean().optional(),
});
export type VercelProjectIntegrationData = z.infer<typeof VercelProjectIntegrationDataSchema>;
export function createDefaultVercelIntegrationData(
vercelProjectId: string,
vercelProjectName: string,
vercelTeamId: string | null,
vercelTeamSlug?: string
): VercelProjectIntegrationData {
return {
config: {
atomicBuilds: ["prod"],
pullEnvVarsBeforeBuild: ["prod", "stg", "preview"],
discoverEnvVars: ["prod", "stg", "preview"],
vercelStagingEnvironment: null,
},
syncEnvVarsMapping: {},
vercelProjectId,
vercelProjectName,
vercelTeamId,
vercelTeamSlug,
};
}
/**
* Maps a Trigger.dev environment type to its Vercel target identifier(s).
* Returns null for STAGING when no custom environment is configured.
*/
export function envTypeToVercelTarget(
envType: TriggerEnvironmentType,
stagingEnvironmentId?: string | null
): string[] | null {
switch (envType) {
case "PRODUCTION":
return ["production"];
case "STAGING":
return stagingEnvironmentId ? [stagingEnvironmentId] : null;
case "PREVIEW":
return ["preview"];
case "DEVELOPMENT":
return ["development"];
}
}
export function getAvailableEnvSlugs(
hasStagingEnvironment: boolean,
hasPreviewEnvironment: boolean
): EnvSlug[] {
return ALL_ENV_SLUGS.filter((s) => {
if (s === "stg" && !hasStagingEnvironment) return false;
if (s === "preview" && !hasPreviewEnvironment) return false;
return true;
});
}
export function getAvailableEnvSlugsForBuildSettings(
hasStagingEnvironment: boolean,
hasPreviewEnvironment: boolean
): EnvSlug[] {
return getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment).filter((s) => s !== "dev");
}
export function isDiscoverEnvVarsEnabledForEnvironment(
discoverEnvVars: EnvSlug[] | null | undefined,
environmentType: TriggerEnvironmentType
): boolean {
if (!discoverEnvVars || discoverEnvVars.length === 0) {
return false;
}
const envSlug = envTypeToSlug(environmentType);
return discoverEnvVars.includes(envSlug);
}
export function envTypeToSlug(environmentType: TriggerEnvironmentType): EnvSlug {
switch (environmentType) {
case "DEVELOPMENT":
return "dev";
case "STAGING":
return "stg";
case "PRODUCTION":
return "prod";
case "PREVIEW":
return "preview";
}
}
export function envSlugToType(slug: EnvSlug): TriggerEnvironmentType {
switch (slug) {
case "dev":
return "DEVELOPMENT";
case "stg":
return "STAGING";
case "prod":
return "PRODUCTION";
case "preview":
return "PREVIEW";
}
}
export function shouldSyncEnvVar(
mapping: SyncEnvVarsMapping,
envVarName: string,
environmentType: TriggerEnvironmentType
): boolean {
const envSlug = envTypeToSlug(environmentType);
const envSettings = mapping[envSlug];
if (!envSettings) {
return true;
}
return envSettings[envVarName] !== false;
}
export function shouldSyncEnvVarForAnyEnvironment(
mapping: SyncEnvVarsMapping,
envVarName: string
): boolean {
for (const slug of ALL_ENV_SLUGS) {
const envSettings = mapping[slug];
if (!envSettings) {
return true;
}
if (envSettings[envVarName] !== false) {
return true;
}
}
return false;
}
export function isPullEnvVarsEnabledForEnvironment(
pullEnvVarsBeforeBuild: EnvSlug[] | null | undefined,
environmentType: TriggerEnvironmentType
): boolean {
if (!pullEnvVarsBeforeBuild || pullEnvVarsBeforeBuild.length === 0) {
return false;
}
const envSlug = envTypeToSlug(environmentType);
return pullEnvVarsBeforeBuild.includes(envSlug);
}
export function isAtomicBuildsEnabledForEnvironment(
atomicBuilds: EnvSlug[] | null | undefined,
environmentType: TriggerEnvironmentType
): boolean {
if (!atomicBuilds || atomicBuilds.length === 0) {
return false;
}
const envSlug = envTypeToSlug(environmentType);
return atomicBuilds.includes(envSlug);
}
@@ -0,0 +1,26 @@
/**
* Validates `next` parameter from Vercel callbacks.
* Only allows vercel.com subdomains (the expected source) and same-origin relative paths.
*/
export function sanitizeVercelNextUrl(url: string | undefined | null): string | undefined {
if (!url) return undefined;
// Allow relative paths (same-origin) but reject protocol-relative URLs
if (url.startsWith("/") && !url.startsWith("//")) {
return url;
}
try {
const parsed = new URL(url);
if (
parsed.protocol === "https:" &&
/^([a-z0-9-]+\.)*vercel\.com$/i.test(parsed.hostname)
) {
return parsed.toString();
}
} catch {
// Invalid URL
}
return undefined;
}
+1
View File
@@ -129,6 +129,7 @@
"@unkey/cache": "^1.5.0",
"@unkey/error": "^0.2.0",
"@upstash/ratelimit": "^1.1.3",
"@vercel/sdk": "^1.19.1",
"@whatwg-node/fetch": "^0.9.14",
"ai": "^4.3.19",
"assert-never": "^1.2.1",
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { sanitizeVercelNextUrl } from "../app/v3/vercel/vercelUrls.server";
describe("sanitizeVercelNextUrl", () => {
it("returns undefined for null/undefined/empty", () => {
expect(sanitizeVercelNextUrl(null)).toBeUndefined();
expect(sanitizeVercelNextUrl(undefined)).toBeUndefined();
expect(sanitizeVercelNextUrl("")).toBeUndefined();
});
it("allows relative paths", () => {
expect(sanitizeVercelNextUrl("/dashboard")).toBe("/dashboard");
expect(sanitizeVercelNextUrl("/some/path?query=1")).toBe("/some/path?query=1");
});
it("rejects protocol-relative URLs", () => {
expect(sanitizeVercelNextUrl("//evil.com/path")).toBeUndefined();
});
it("allows vercel.com URLs", () => {
expect(sanitizeVercelNextUrl("https://vercel.com/dashboard")).toBe(
"https://vercel.com/dashboard"
);
expect(sanitizeVercelNextUrl("https://app.vercel.com/settings")).toBe(
"https://app.vercel.com/settings"
);
});
it("allows vercel.com subdomains", () => {
expect(sanitizeVercelNextUrl("https://my-team.vercel.com/project")).toBe(
"https://my-team.vercel.com/project"
);
});
it("rejects non-vercel HTTPS URLs", () => {
expect(sanitizeVercelNextUrl("https://evil.com/path")).toBeUndefined();
expect(sanitizeVercelNextUrl("https://not-vercel.com")).toBeUndefined();
expect(sanitizeVercelNextUrl("https://vercel.com.evil.com")).toBeUndefined();
});
it("rejects HTTP vercel.com URLs", () => {
expect(sanitizeVercelNextUrl("http://vercel.com/dashboard")).toBeUndefined();
});
it("rejects javascript: URLs", () => {
expect(sanitizeVercelNextUrl("javascript:alert(1)")).toBeUndefined();
});
it("rejects data: URLs", () => {
expect(sanitizeVercelNextUrl("data:text/html,<script>alert(1)</script>")).toBeUndefined();
});
it("rejects invalid URLs", () => {
expect(sanitizeVercelNextUrl("not a url at all")).toBeUndefined();
});
});
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."EnvironmentVariableValue" ADD COLUMN "lastUpdatedBy" JSONB,
ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "public"."OrganizationProjectIntegration" (
"id" TEXT NOT NULL,
"organizationIntegrationId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"externalEntityId" TEXT NOT NULL,
"integrationData" JSONB NOT NULL,
"installedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "OrganizationProjectIntegration_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "OrganizationProjectIntegration_projectId_idx" ON "public"."OrganizationProjectIntegration"("projectId");
-- CreateIndex
CREATE INDEX "OrganizationProjectIntegration_projectId_organizationIntegr_idx" ON "public"."OrganizationProjectIntegration"("projectId", "organizationIntegrationId");
-- CreateIndex
CREATE INDEX "OrganizationProjectIntegration_externalEntityId_idx" ON "public"."OrganizationProjectIntegration"("externalEntityId");
-- AddForeignKey
ALTER TABLE "public"."OrganizationProjectIntegration" ADD CONSTRAINT "OrganizationProjectIntegration_organizationIntegrationId_fkey" FOREIGN KEY ("organizationIntegrationId") REFERENCES "public"."OrganizationIntegration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."OrganizationProjectIntegration" ADD CONSTRAINT "OrganizationProjectIntegration_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "public"."IntegrationDeployment" (
"id" TEXT NOT NULL,
"integrationName" TEXT NOT NULL,
"integrationDeploymentId" TEXT NOT NULL,
"commitSHA" TEXT NOT NULL,
"deploymentId" TEXT,
"status" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntegrationDeployment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IntegrationDeployment_deploymentId_idx" ON "public"."IntegrationDeployment"("deploymentId");
-- CreateIndex
CREATE INDEX "IntegrationDeployment_commitSHA_idx" ON "public"."IntegrationDeployment"("commitSHA");
-- AddForeignKey
ALTER TABLE "public"."IntegrationDeployment" ADD CONSTRAINT "IntegrationDeployment_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "public"."WorkerDeployment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- AlterEnum
ALTER TYPE "public"."IntegrationService" ADD VALUE 'VERCEL';
-- AlterTable
ALTER TABLE "public"."OrganizationIntegration" ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "externalOrganizationId" TEXT;
-- AlterTable
ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "commitSHA" TEXT;
@@ -0,0 +1,3 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "OrganizationIntegration_externalOrganizationId_idx" ON "public"."OrganizationIntegration"("externalOrganizationId");
@@ -0,0 +1,3 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "WorkerDeployment_commitSHA_idx" ON "public"."WorkerDeployment"("commitSHA");
+83 -30
View File
@@ -384,28 +384,29 @@ model Project {
/// The master queues they are allowed to use (impacts what they can set as default and trigger runs with)
allowedWorkerQueues String[] @default([]) @map("allowedMasterQueues")
environments RuntimeEnvironment[]
backgroundWorkers BackgroundWorker[]
backgroundWorkerTasks BackgroundWorkerTask[]
taskRuns TaskRun[]
runTags TaskRunTag[]
taskQueues TaskQueue[]
environmentVariables EnvironmentVariable[]
checkpoints Checkpoint[]
WorkerDeployment WorkerDeployment[]
CheckpointRestoreEvent CheckpointRestoreEvent[]
taskSchedules TaskSchedule[]
alertChannels ProjectAlertChannel[]
alerts ProjectAlert[]
alertStorages ProjectAlertStorage[]
bulkActionGroups BulkActionGroup[]
BackgroundWorkerFile BackgroundWorkerFile[]
waitpoints Waitpoint[]
taskRunWaitpoints TaskRunWaitpoint[]
taskRunCheckpoints TaskRunCheckpoint[]
waitpointTags WaitpointTag[]
connectedGithubRepository ConnectedGithubRepository?
customerQueries CustomerQuery[]
environments RuntimeEnvironment[]
backgroundWorkers BackgroundWorker[]
backgroundWorkerTasks BackgroundWorkerTask[]
taskRuns TaskRun[]
runTags TaskRunTag[]
taskQueues TaskQueue[]
environmentVariables EnvironmentVariable[]
checkpoints Checkpoint[]
WorkerDeployment WorkerDeployment[]
CheckpointRestoreEvent CheckpointRestoreEvent[]
taskSchedules TaskSchedule[]
alertChannels ProjectAlertChannel[]
alerts ProjectAlert[]
alertStorages ProjectAlertStorage[]
bulkActionGroups BulkActionGroup[]
BackgroundWorkerFile BackgroundWorkerFile[]
waitpoints Waitpoint[]
taskRunWaitpoints TaskRunWaitpoint[]
taskRunCheckpoints TaskRunCheckpoint[]
waitpointTags WaitpointTag[]
connectedGithubRepository ConnectedGithubRepository?
organizationProjectIntegration OrganizationProjectIntegration[]
customerQueries CustomerQuery[]
buildSettings Json?
taskScheduleInstances TaskScheduleInstance[]
@@ -1712,6 +1713,9 @@ model EnvironmentVariableValue {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
version Int @default(1)
lastUpdatedBy Json?
@@unique([variableId, environmentId])
}
@@ -1825,9 +1829,10 @@ model WorkerDeployment {
worker BackgroundWorker? @relation(fields: [workerId], references: [id], onDelete: Cascade, onUpdate: Cascade)
workerId String? @unique
triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade)
triggeredById String?
triggeredVia String?
triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade)
triggeredById String?
triggeredVia String?
commitSHA String?
startedAt DateTime?
installedAt DateTime?
@@ -1846,12 +1851,14 @@ model WorkerDeployment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
promotions WorkerDeploymentPromotion[]
alerts ProjectAlert[]
workerInstance WorkerInstance[]
promotions WorkerDeploymentPromotion[]
alerts ProjectAlert[]
workerInstance WorkerInstance[]
integrationDeployments IntegrationDeployment[]
@@unique([projectId, shortCode])
@@unique([environmentId, version])
@@index([commitSHA])
}
enum WorkerDeploymentStatus {
@@ -2088,7 +2095,8 @@ model OrganizationIntegration {
friendlyId String @unique
service IntegrationService
service IntegrationService
externalOrganizationId String? /// Identifier for external, integration's organization (e.g. Vercel's team)
integrationData Json
@@ -2100,12 +2108,39 @@ model OrganizationIntegration {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
alertChannels ProjectAlertChannel[]
alertChannels ProjectAlertChannel[]
organizationProjectIntegration OrganizationProjectIntegration[]
@@index([externalOrganizationId])
}
model OrganizationProjectIntegration {
id String @id @default(cuid())
organizationIntegration OrganizationIntegration @relation(fields: [organizationIntegrationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationIntegrationId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
externalEntityId String /// Identifier for webhooks, for example Vercel's projectId
integrationData Json /// Save useful data like config or external entity name
installedBy String? /// UserId who installed the integration
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([projectId])
@@index([projectId, organizationIntegrationId])
@@index([externalEntityId])
}
enum IntegrationService {
SLACK
VERCEL
}
/// Bulk actions, like canceling and replaying runs
@@ -2486,3 +2521,21 @@ model CustomerQuery {
/// For Stripe metering job - find unprocessed queries
@@index([createdAt])
}
model IntegrationDeployment {
id String @id @default(cuid())
integrationName String /// For example Vercel
integrationDeploymentId String /// External ID
commitSHA String
deploymentId String?
status String? /// External deployment status
workerDeployment WorkerDeployment? @relation(fields: [deploymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([commitSHA])
@@index([deploymentId])
}
+18
View File
@@ -694,6 +694,7 @@ export const GetDeploymentResponseBody = z.object({
version: z.string(),
imageReference: z.string().nullish(),
imagePlatform: z.string(),
commitSHA: z.string().nullish(),
externalBuildData: ExternalBuildData.optional().nullable(),
errorData: DeploymentErrorData.nullish(),
worker: z
@@ -710,6 +711,17 @@ export const GetDeploymentResponseBody = z.object({
),
})
.optional(),
integrationDeployments: z
.array(
z.object({
id: z.string(),
integrationName: z.string(),
integrationDeploymentId: z.string(),
commitSHA: z.string(),
createdAt: z.coerce.date(),
})
)
.nullish(),
});
export type GetDeploymentResponseBody = z.infer<typeof GetDeploymentResponseBody>;
@@ -1139,6 +1151,12 @@ export const ImportEnvironmentVariablesRequestBody = z.object({
variables: z.record(z.string()),
parentVariables: z.record(z.string()).optional(),
override: z.boolean().optional(),
source: z
.discriminatedUnion("type", [
z.object({ type: z.literal("user"), userId: z.string() }),
z.object({ type: z.literal("integration"), integration: z.string() }),
])
.optional(),
});
export type ImportEnvironmentVariablesRequestBody = z.infer<
+292 -43
View File
@@ -521,6 +521,9 @@ importers:
'@upstash/ratelimit':
specifier: ^1.1.3
version: 1.1.3(patch_hash=e5922e50fbefb7b2b24950c4b1c5c9ddc4cd25464439c9548d2298c432debe74)
'@vercel/sdk':
specifier: ^1.19.1
version: 1.19.1
'@whatwg-node/fetch':
specifier: ^0.9.14
version: 0.9.14
@@ -1417,7 +1420,7 @@ importers:
version: 0.0.1-cli.2.80.0
'@modelcontextprotocol/sdk':
specifier: ^1.25.2
version: 1.25.2(hono@4.5.11)(supports-color@10.0.0)(zod@3.25.76)
version: 1.25.2(hono@4.11.8)(supports-color@10.0.0)(zod@3.25.76)
'@opentelemetry/api':
specifier: 1.9.0
version: 1.9.0
@@ -1785,7 +1788,7 @@ importers:
version: 4.0.14
ai:
specifier: ^6.0.0
version: 6.0.39(zod@3.25.76)
version: 6.0.3(zod@3.25.76)
defu:
specifier: ^6.1.4
version: 6.1.4
@@ -2070,7 +2073,7 @@ importers:
version: 8.5.4
ai:
specifier: ^6.0.0
version: 6.0.39(zod@3.25.76)
version: 6.0.3(zod@3.25.76)
encoding:
specifier: ^0.1.13
version: 0.1.13
@@ -2436,7 +2439,7 @@ importers:
version: link:../../packages/trigger-sdk
'@uploadthing/react':
specifier: ^7.0.3
version: 7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))
version: 7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))
ai:
specifier: ^4.0.0
version: 4.0.0(react@18.3.1)(zod@3.25.76)
@@ -2475,7 +2478,7 @@ importers:
version: 1.0.7(tailwindcss@3.4.1)
uploadthing:
specifier: ^7.1.0
version: 7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1)
version: 7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1)
zod:
specifier: 3.25.76
version: 3.25.76
@@ -2843,8 +2846,8 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.16':
resolution: {integrity: sha512-OOY5CfRJiHvh/8np2vs1RQaCZ5hWv2qOeEmmeiABXK3gLQHUVnCO+1hhoLsZdHM5iElu6M407dAOfyvTsKJqcQ==}
'@ai-sdk/gateway@3.0.2':
resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -2921,8 +2924,8 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/provider-utils@4.0.8':
resolution: {integrity: sha512-ns9gN7MmpI8vTRandzgz+KK/zNMLzhrriiKECMt4euLtQFSBgNfydtagPOX4j4pS1/3KvHF6RivhT3gNQgBZsg==}
'@ai-sdk/provider-utils@4.0.1':
resolution: {integrity: sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -2947,8 +2950,8 @@ packages:
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
engines: {node: '>=18'}
'@ai-sdk/provider@3.0.4':
resolution: {integrity: sha512-5KXyBOSEX+l67elrEa+wqo/LSsSTtrPj9Uoh3zMbe/ceQX4ucHI3b9nUEfNkGF3Ry1svv90widAt+aiKdIJasQ==}
'@ai-sdk/provider@3.0.0':
resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==}
engines: {node: '>=18'}
'@ai-sdk/react@1.0.0':
@@ -5872,6 +5875,16 @@ packages:
'@cfworker/json-schema':
optional: true
'@modelcontextprotocol/sdk@1.26.0':
resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
zod: ^3.25 || ^4.0
peerDependenciesMeta:
'@cfworker/json-schema':
optional: true
'@msgpack/msgpack@3.0.0-beta2':
resolution: {integrity: sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==}
engines: {node: '>= 14'}
@@ -11094,8 +11107,8 @@ packages:
resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==}
engines: {node: '>= 20'}
'@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
'@vercel/oidc@3.0.5':
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
engines: {node: '>= 20'}
'@vercel/otel@1.13.0':
@@ -11115,6 +11128,10 @@ packages:
engines: {node: '>=18.14'}
deprecated: '@vercel/postgres is deprecated. You can either choose an alternate storage solution from the Vercel Marketplace if you want to set up a new database. Or you can follow this guide to migrate your existing Vercel Postgres db: https://neon.com/docs/guides/vercel-postgres-transition-guide'
'@vercel/sdk@1.19.1':
resolution: {integrity: sha512-K4rmtUT6t1vX06tiY44ot8A7W1FKN7g/tMkE7yZghCgNQ8b30SzljBd4ni8RNp2pJzM/HrZmphRDeIArO7oZuw==}
hasBin: true
'@vitest/coverage-v8@3.1.4':
resolution: {integrity: sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==}
peerDependencies:
@@ -11435,8 +11452,8 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@6.0.39:
resolution: {integrity: sha512-hF05gF4H+IxuilA8kNANVVHQXduTJsJaH74jmlmy8mcQt3NZgPYe2zZNyGBV4DPDYTUDt1h31hbLgQqJTn5LGA==}
ai@6.0.3:
resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -11806,6 +11823,10 @@ packages:
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
engines: {node: '>=18'}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
bottleneck@2.19.5:
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
@@ -12764,6 +12785,15 @@ packages:
supports-color:
optional: true
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
decamelize-keys@1.1.1:
resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==}
engines: {node: '>=0.10.0'}
@@ -13720,6 +13750,12 @@ packages:
peerDependencies:
express: ^4.11 || 5 || ^5.0.0-beta.1
express-rate-limit@8.2.1:
resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@4.20.0:
resolution: {integrity: sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==}
engines: {node: '>= 0.10.0'}
@@ -13728,6 +13764,10 @@ packages:
resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==}
engines: {node: '>= 18'}
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
@@ -14381,6 +14421,10 @@ packages:
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
hono@4.11.8:
resolution: {integrity: sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==}
engines: {node: '>=16.9.0'}
hono@4.5.11:
resolution: {integrity: sha512-62FcjLPtjAFwISVBUshryl+vbHOjg8rE4uIK/dxyR8GpLztunZpwFmfEvmJCUI7xoGh/Sr3CGCDPCmYxVw7wUQ==}
engines: {node: '>=16.0.0'}
@@ -14419,6 +14463,10 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -14467,6 +14515,10 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
icss-utils@5.1.0:
resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==}
engines: {node: ^10 || ^12 || >= 14}
@@ -14579,6 +14631,10 @@ packages:
resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==}
engines: {node: '>=12.22.0'}
ip-address@10.0.1:
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
engines: {node: '>= 12'}
ip-address@9.0.5:
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
engines: {node: '>= 12'}
@@ -15841,6 +15897,10 @@ packages:
resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
@@ -15849,6 +15909,10 @@ packages:
resolution: {integrity: sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==}
engines: {node: '>= 0.6'}
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
@@ -17498,6 +17562,10 @@ packages:
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
engines: {node: '>= 0.8'}
raw-body@3.0.2:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
@@ -18073,6 +18141,10 @@ packages:
resolution: {integrity: sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==}
engines: {node: '>= 18'}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
rtl-css-js@1.16.1:
resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==}
@@ -18200,6 +18272,10 @@ packages:
resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==}
engines: {node: '>= 18'}
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
serialize-javascript@6.0.1:
resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==}
@@ -18214,6 +18290,10 @@ packages:
resolution: {integrity: sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==}
engines: {node: '>= 18'}
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -18513,6 +18593,10 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@3.7.0:
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==}
@@ -18824,10 +18908,6 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'}
tapable@2.2.2:
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
engines: {node: '>=6'}
tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
@@ -19311,6 +19391,10 @@ packages:
resolution: {integrity: sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==}
engines: {node: '>= 0.6'}
type-is@2.0.1:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
typed-array-buffer@1.0.2:
resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==}
engines: {node: '>= 0.4'}
@@ -20079,6 +20163,11 @@ packages:
peerDependencies:
zod: ^3.25 || ^4
zod-to-json-schema@3.25.1:
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
peerDependencies:
zod: ^3.25 || ^4
zod-validation-error@1.5.0:
resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==}
engines: {node: '>=16.0.0'}
@@ -20132,11 +20221,11 @@ snapshots:
'@vercel/oidc': 3.0.3
zod: 3.25.76
'@ai-sdk/gateway@3.0.16(zod@3.25.76)':
'@ai-sdk/gateway@3.0.2(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.4
'@ai-sdk/provider-utils': 4.0.8(zod@3.25.76)
'@vercel/oidc': 3.1.0
'@ai-sdk/provider': 3.0.0
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
'@vercel/oidc': 3.0.5
zod: 3.25.76
'@ai-sdk/openai@1.0.1(zod@3.25.76)':
@@ -20216,9 +20305,9 @@ snapshots:
zod: 3.25.76
zod-to-json-schema: 3.24.6(zod@3.25.76)
'@ai-sdk/provider-utils@4.0.8(zod@3.25.76)':
'@ai-sdk/provider-utils@4.0.1(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.4
'@ai-sdk/provider': 3.0.0
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 3.25.76
@@ -20243,7 +20332,7 @@ snapshots:
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@3.0.4':
'@ai-sdk/provider@3.0.0':
dependencies:
json-schema: 0.4.0
@@ -23756,9 +23845,9 @@ snapshots:
dependencies:
hono: 4.5.11
'@hono/node-server@1.19.9(hono@4.5.11)':
'@hono/node-server@1.19.9(hono@4.11.8)':
dependencies:
hono: 4.5.11
hono: 4.11.8
'@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)':
dependencies:
@@ -24039,7 +24128,7 @@ snapshots:
'@jridgewell/source-map@0.3.3':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/sourcemap-codec@1.5.0': {}
@@ -24220,9 +24309,9 @@ snapshots:
'@microsoft/fetch-event-source@2.0.1': {}
'@modelcontextprotocol/sdk@1.25.2(hono@4.5.11)(supports-color@10.0.0)(zod@3.25.76)':
'@modelcontextprotocol/sdk@1.25.2(hono@4.11.8)(supports-color@10.0.0)(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.5.11)
'@hono/node-server': 1.19.9(hono@4.11.8)
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
content-type: 1.0.5
@@ -24242,6 +24331,28 @@ snapshots:
- hono
- supports-color
'@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.11.8)
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
content-type: 1.0.5
cors: 2.8.5
cross-spawn: 7.0.6
eventsource: 3.0.5
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.2.1(express@5.2.1)
hono: 4.11.8
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.0
raw-body: 3.0.0
zod: 3.25.76
zod-to-json-schema: 3.25.1(zod@3.25.76)
transitivePeerDependencies:
- supports-color
'@msgpack/msgpack@3.0.0-beta2': {}
'@neondatabase/serverless@0.9.5':
@@ -31234,12 +31345,12 @@ snapshots:
'@uploadthing/mime-types@0.3.0': {}
'@uploadthing/react@7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))':
'@uploadthing/react@7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))':
dependencies:
'@uploadthing/shared': 7.0.3
file-selector: 0.6.0
react: 18.3.1
uploadthing: 7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1)
uploadthing: 7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1)
optionalDependencies:
next: 14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)
@@ -31310,7 +31421,7 @@ snapshots:
'@vercel/oidc@3.0.3': {}
'@vercel/oidc@3.1.0': {}
'@vercel/oidc@3.0.5': {}
'@vercel/otel@1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))':
dependencies:
@@ -31330,6 +31441,14 @@ snapshots:
transitivePeerDependencies:
- utf-8-validate
'@vercel/sdk@1.19.1':
dependencies:
'@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76)
zod: 3.25.76
transitivePeerDependencies:
- '@cfworker/json-schema'
- supports-color
'@vitest/coverage-v8@3.1.4(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))':
dependencies:
'@ampproject/remapping': 2.3.0
@@ -31739,11 +31858,11 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ai@6.0.39(zod@3.25.76):
ai@6.0.3(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.16(zod@3.25.76)
'@ai-sdk/provider': 3.0.4
'@ai-sdk/provider-utils': 4.0.8(zod@3.25.76)
'@ai-sdk/gateway': 3.0.2(zod@3.25.76)
'@ai-sdk/provider': 3.0.0
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
@@ -32170,6 +32289,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3
http-errors: 2.0.0
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.14.1
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
- supports-color
bottleneck@2.19.5: {}
bowser@2.11.0: {}
@@ -33158,6 +33291,10 @@ snapshots:
optionalDependencies:
supports-color: 10.0.0
debug@4.4.3:
dependencies:
ms: 2.1.3
decamelize-keys@1.1.1:
dependencies:
decamelize: 1.2.0
@@ -33515,7 +33652,7 @@ snapshots:
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.2
tapable: 2.3.0
enquirer@2.3.6:
dependencies:
@@ -34393,6 +34530,11 @@ snapshots:
dependencies:
express: 5.0.1(supports-color@10.0.0)
express-rate-limit@8.2.1(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.0.1
express@4.20.0:
dependencies:
accepts: 1.3.8
@@ -34466,6 +34608,39 @@ snapshots:
transitivePeerDependencies:
- supports-color
express@5.2.1:
dependencies:
accepts: 2.0.0
body-parser: 2.2.2
content-disposition: 1.0.0
content-type: 1.0.5
cookie: 0.7.1
cookie-signature: 1.2.2
debug: 4.4.1(supports-color@10.0.0)
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 2.1.0(supports-color@10.0.0)
fresh: 2.0.0
http-errors: 2.0.0
merge-descriptors: 2.0.0
mime-types: 3.0.0
on-finished: 2.4.1
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.14.1
range-parser: 1.2.1
router: 2.2.0
send: 1.1.0(supports-color@10.0.0)
serve-static: 2.2.1
statuses: 2.0.1
type-is: 2.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
exsolve@1.0.7: {}
extend@3.0.2: {}
@@ -35301,6 +35476,8 @@ snapshots:
dependencies:
react-is: 16.13.1
hono@4.11.8: {}
hono@4.5.11: {}
hosted-git-info@2.8.9: {}
@@ -35342,6 +35519,14 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
http-errors@2.0.1:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -35393,6 +35578,10 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
icss-utils@5.1.0(postcss@8.4.35):
dependencies:
postcss: 8.4.35
@@ -35507,6 +35696,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
ip-address@10.0.1: {}
ip-address@9.0.5:
dependencies:
jsbn: 1.1.0
@@ -37070,6 +37261,8 @@ snapshots:
mime-db@1.53.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
@@ -37078,6 +37271,10 @@ snapshots:
dependencies:
mime-db: 1.53.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
mime@1.6.0: {}
mime@2.6.0: {}
@@ -38790,6 +38987,13 @@ snapshots:
iconv-lite: 0.6.3
unpipe: 1.0.0
raw-body@3.0.2:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.7.2
unpipe: 1.0.0
rc9@2.1.2:
dependencies:
defu: 6.1.4
@@ -39705,6 +39909,16 @@ snapshots:
parseurl: 1.3.3
path-to-regexp: 8.2.0
router@2.2.0:
dependencies:
debug: 4.4.1(supports-color@10.0.0)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
path-to-regexp: 8.2.0
transitivePeerDependencies:
- supports-color
rtl-css-js@1.16.1:
dependencies:
'@babel/runtime': 7.28.4
@@ -39866,6 +40080,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
send@1.2.1:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 2.0.0
http-errors: 2.0.1
mime-types: 3.0.2
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
serialize-javascript@6.0.1:
dependencies:
randombytes: 2.1.0
@@ -39892,6 +40122,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 1.2.1
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-cookie-parser@2.6.0: {}
@@ -40313,6 +40552,8 @@ snapshots:
statuses@2.0.1: {}
statuses@2.0.2: {}
std-env@3.7.0: {}
std-env@3.8.1: {}
@@ -40743,8 +40984,6 @@ snapshots:
tapable@2.2.1: {}
tapable@2.2.2: {}
tapable@2.3.0: {}
tar-fs@2.1.3:
@@ -41262,6 +41501,12 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.0
type-is@2.0.1:
dependencies:
content-type: 1.0.5
media-typer: 1.1.0
mime-types: 3.0.0
typed-array-buffer@1.0.2:
dependencies:
call-bind: 1.0.8
@@ -41472,7 +41717,7 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uploadthing@7.1.0(express@5.0.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1):
uploadthing@7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1):
dependencies:
'@effect/platform': 0.63.2(@effect/schema@0.72.2(effect@3.7.2))(effect@3.7.2)
'@effect/schema': 0.72.2(effect@3.7.2)
@@ -41480,7 +41725,7 @@ snapshots:
'@uploadthing/shared': 7.0.3
effect: 3.7.2
optionalDependencies:
express: 5.0.1(supports-color@10.0.0)
express: 5.2.1
fastify: 5.4.0
next: 14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)
tailwindcss: 3.4.1
@@ -42118,6 +42363,10 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1(zod@3.25.76):
dependencies:
zod: 3.25.76
zod-validation-error@1.5.0(zod@3.25.76):
dependencies:
zod: 3.25.76