chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import { redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import type { SearchParams } from "~/routes/admin._index";
|
||||
import {
|
||||
clearImpersonationId,
|
||||
commitImpersonationSession,
|
||||
getImpersonationId,
|
||||
setImpersonationId,
|
||||
} from "~/services/impersonation.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { extractClientIp } from "~/utils/extractClientIp.server";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
export async function adminGetUsers(userId: string, { page, search }: SearchParams) {
|
||||
page = page || 1;
|
||||
|
||||
search = search ? decodeURIComponent(search) : undefined;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.admin !== true) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
admin: true,
|
||||
createdAt: true,
|
||||
displayName: true,
|
||||
orgMemberships: {
|
||||
select: {
|
||||
organization: {
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: search
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
email: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
orgMemberships: {
|
||||
some: {
|
||||
organization: {
|
||||
title: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
orgMemberships: {
|
||||
some: {
|
||||
organization: {
|
||||
slug: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
});
|
||||
|
||||
const totalUsers = await prisma.user.count();
|
||||
|
||||
return {
|
||||
users,
|
||||
page,
|
||||
pageCount: Math.ceil(totalUsers / pageSize),
|
||||
filters: {
|
||||
search,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function adminGetOrganizations(userId: string, { page, search }: SearchParams) {
|
||||
page = page || 1;
|
||||
|
||||
search = search ? decodeURIComponent(search) : undefined;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.admin !== true) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const organizations = await prisma.organization.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
v2Enabled: true,
|
||||
isActivated: true,
|
||||
deletedAt: true,
|
||||
members: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: search
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
user: {
|
||||
name: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
user: {
|
||||
email: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
});
|
||||
|
||||
const totalOrgs = await prisma.organization.count();
|
||||
|
||||
return {
|
||||
organizations,
|
||||
page,
|
||||
pageCount: Math.ceil(totalOrgs / pageSize),
|
||||
filters: {
|
||||
search,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function redirectWithImpersonation(
|
||||
request: Request,
|
||||
userId: string,
|
||||
path: string,
|
||||
currentUser?: { id: string; admin: boolean }
|
||||
) {
|
||||
const user = currentUser ?? (await requireUser(request));
|
||||
if (!user.admin) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const xff = request.headers.get("x-forwarded-for");
|
||||
const ipAddress = extractClientIp(xff);
|
||||
|
||||
try {
|
||||
await prisma.impersonationAuditLog.create({
|
||||
data: {
|
||||
action: "START",
|
||||
adminId: user.id,
|
||||
targetId: userId,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to create impersonation audit log", {
|
||||
error,
|
||||
adminId: user.id,
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
const session = await setImpersonationId(userId, request);
|
||||
|
||||
return redirect(path, {
|
||||
headers: { "Set-Cookie": await commitImpersonationSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearImpersonation(request: Request, path: string) {
|
||||
const authUser = await authenticator.isAuthenticated(request);
|
||||
const targetId = await getImpersonationId(request);
|
||||
|
||||
if (targetId && authUser?.userId) {
|
||||
const xff = request.headers.get("x-forwarded-for");
|
||||
const ipAddress = extractClientIp(xff);
|
||||
|
||||
try {
|
||||
await prisma.impersonationAuditLog.create({
|
||||
data: {
|
||||
action: "STOP",
|
||||
adminId: authUser.userId,
|
||||
targetId,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to create impersonation audit log", {
|
||||
error,
|
||||
adminId: authUser.userId,
|
||||
targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const session = await clearImpersonationId(request);
|
||||
|
||||
return redirect(path, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitImpersonationSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { RuntimeEnvironmentType } from "~/database-types";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
|
||||
const apiKeyId = customAlphabet(
|
||||
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
12
|
||||
);
|
||||
|
||||
const REVOKED_API_KEY_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type RegenerateAPIKeyInput = {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIKeyInput) {
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
// check if the user is part of the org
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
id: environment.organization.id,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("User does not have permission to regenerate API key");
|
||||
}
|
||||
|
||||
// check if it is the user's dev environment
|
||||
if (environment.type === RuntimeEnvironmentType.DEVELOPMENT) {
|
||||
if (!environment.orgMemberId) {
|
||||
throw new Error("User does not have permission to regenerate API key");
|
||||
}
|
||||
|
||||
const orgMember = await prisma.orgMember.findFirst({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
userId: userId,
|
||||
id: environment.orgMemberId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgMember) {
|
||||
throw new Error("User does not have permission to regenerate API key");
|
||||
}
|
||||
}
|
||||
|
||||
// generate and store new keys
|
||||
const newApiKey = createApiKeyForEnv(environment.type);
|
||||
const newPkApiKey = createPkApiKeyForEnv(environment.type);
|
||||
|
||||
const revokedApiKeyExpiresAt = new Date(Date.now() + REVOKED_API_KEY_GRACE_PERIOD_MS);
|
||||
|
||||
const updatedEnviroment = await prisma.$transaction(async (tx) => {
|
||||
await tx.revokedApiKey.create({
|
||||
data: {
|
||||
apiKey: environment.apiKey,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
expiresAt: revokedApiKeyExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return tx.runtimeEnvironment.update({
|
||||
data: {
|
||||
apiKey: newApiKey,
|
||||
pkApiKey: newPkApiKey,
|
||||
},
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// The env's apiKey changed in the control-plane; drop any cached copy.
|
||||
controlPlaneResolver.invalidateEnvironment(environmentId);
|
||||
|
||||
return updatedEnviroment;
|
||||
}
|
||||
|
||||
export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
|
||||
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
|
||||
}
|
||||
|
||||
export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
|
||||
return `pk_${envSlug(envType)}_${apiKeyId(20)}`;
|
||||
}
|
||||
|
||||
export type EnvSlug = "dev" | "stg" | "prod" | "preview";
|
||||
|
||||
export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug {
|
||||
switch (environmentType) {
|
||||
case "DEVELOPMENT": {
|
||||
return "dev";
|
||||
}
|
||||
case "PRODUCTION": {
|
||||
return "prod";
|
||||
}
|
||||
case "STAGING": {
|
||||
return "stg";
|
||||
}
|
||||
case "PREVIEW": {
|
||||
return "preview";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isEnvSlug(maybeSlug: string): maybeSlug is EnvSlug {
|
||||
return ["dev", "stg", "prod", "preview"].includes(maybeSlug);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import type { Organization, OrgMember, Project } from "@trigger.dev/database";
|
||||
import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server";
|
||||
import { createEnvironment } from "./organization.server";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { ssoController } from "~/services/sso.server";
|
||||
|
||||
export const INVITE_NOT_FOUND = "Invite not found";
|
||||
export const INVITE_BLOCKED_DIRECTORY_MANAGED =
|
||||
"Membership for this organization is managed by Directory Sync, so invites can't be accepted.";
|
||||
export const ENV_SETUP_INCOMPLETE =
|
||||
"You joined the organization, but we couldn't finish setting up your development environments. Please try accepting the invite again, or contact support if this persists.";
|
||||
|
||||
export function isAcceptInviteFormError(error: unknown): error is Error {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === INVITE_NOT_FOUND ||
|
||||
error.message === ENV_SETUP_INCOMPLETE ||
|
||||
error.message === INVITE_BLOCKED_DIRECTORY_MANAGED)
|
||||
);
|
||||
}
|
||||
|
||||
const tokenValueLength = 40;
|
||||
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
|
||||
|
||||
export async function getTeamMembersAndInvites({
|
||||
userId,
|
||||
organizationId,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
}) {
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { id: organizationId, members: { some: { userId } } },
|
||||
select: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invites: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
updatedAt: true,
|
||||
inviter: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { members: org.members, invites: org.invites };
|
||||
}
|
||||
|
||||
export async function inviteMembers({
|
||||
slug,
|
||||
emails,
|
||||
userId,
|
||||
rbacRoleId,
|
||||
}: {
|
||||
slug: string;
|
||||
emails: string[];
|
||||
userId: string;
|
||||
/**
|
||||
* Optional RBAC role to attach to the invite. When set, accepted
|
||||
* invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember
|
||||
* is created.
|
||||
*
|
||||
* `OrgMemberInvite.role` is still set if the plugin isn't installed.
|
||||
*/
|
||||
rbacRoleId?: string | null;
|
||||
}) {
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { slug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Error("User does not have access to this organization");
|
||||
}
|
||||
|
||||
const invites = [...new Set(emails)].map(
|
||||
(email) =>
|
||||
({
|
||||
email,
|
||||
token: tokenGenerator(),
|
||||
organizationId: org.id,
|
||||
inviterId: userId,
|
||||
role: "MEMBER",
|
||||
rbacRoleId: rbacRoleId ?? null,
|
||||
}) satisfies Prisma.OrgMemberInviteCreateManyInput
|
||||
);
|
||||
|
||||
await prisma.orgMemberInvite.createMany({
|
||||
data: invites,
|
||||
});
|
||||
|
||||
return await prisma.orgMemberInvite.findMany({
|
||||
where: {
|
||||
organizationId: org.id,
|
||||
inviterId: userId,
|
||||
email: {
|
||||
in: emails,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
inviter: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInviteFromToken({ token }: { token: string }) {
|
||||
return await prisma.orgMemberInvite.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
inviter: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUsersInvites({ email }: { email: string }) {
|
||||
return await prisma.orgMemberInvite.findMany({
|
||||
where: {
|
||||
email,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
inviter: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getProjectsMissingMemberDevelopmentEnvironments({
|
||||
memberId,
|
||||
organizationId,
|
||||
projects,
|
||||
}: {
|
||||
memberId: string;
|
||||
organizationId: string;
|
||||
projects: Pick<Project, "id">[];
|
||||
}) {
|
||||
if (projects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingEnvs = await prisma.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
orgMemberId: memberId,
|
||||
organizationId,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: { in: projects.map((project) => project.id) },
|
||||
},
|
||||
select: { projectId: true },
|
||||
});
|
||||
const existingProjectIds = new Set(existingEnvs.map((env) => env.projectId));
|
||||
|
||||
return projects.filter((project) => !existingProjectIds.has(project.id));
|
||||
}
|
||||
|
||||
export async function provisionMemberDevelopmentEnvironments({
|
||||
inviteId,
|
||||
user,
|
||||
member,
|
||||
organization,
|
||||
projects,
|
||||
maximumConcurrencyLimit,
|
||||
}: {
|
||||
inviteId: string;
|
||||
user: { id: string; email: string };
|
||||
member: OrgMember;
|
||||
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
|
||||
projects: Pick<Project, "id">[];
|
||||
maximumConcurrencyLimit: number;
|
||||
}) {
|
||||
const projectsNeedingEnvs = await getProjectsMissingMemberDevelopmentEnvironments({
|
||||
memberId: member.id,
|
||||
organizationId: organization.id,
|
||||
projects,
|
||||
});
|
||||
const projectIds = projects.map((project) => project.id);
|
||||
const createdProjectIds: string[] = [];
|
||||
let failedProjectId: string | undefined;
|
||||
let failedProjectIndex: number | undefined;
|
||||
|
||||
try {
|
||||
for (const [index, project] of projectsNeedingEnvs.entries()) {
|
||||
failedProjectId = project.id;
|
||||
failedProjectIndex = index;
|
||||
|
||||
await createEnvironment({
|
||||
organization,
|
||||
project,
|
||||
type: "DEVELOPMENT",
|
||||
// We set this true but no backfill (yet!?) so never used
|
||||
// for dev environments
|
||||
isBranchableEnvironment: true,
|
||||
member,
|
||||
maximumConcurrencyLimit,
|
||||
});
|
||||
|
||||
createdProjectIds.push(project.id);
|
||||
failedProjectId = undefined;
|
||||
failedProjectIndex = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("acceptInvite: development environment creation failed after membership created", {
|
||||
inviteId,
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
orgMemberId: member.id,
|
||||
projectIds,
|
||||
failedProjectId,
|
||||
failedProjectIndex,
|
||||
totalProjects: projectsNeedingEnvs.length,
|
||||
createdProjectIds,
|
||||
error:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: String(error),
|
||||
});
|
||||
|
||||
throw new Error(ENV_SETUP_INCOMPLETE);
|
||||
}
|
||||
}
|
||||
|
||||
async function assignInviteRbacRole({
|
||||
userId,
|
||||
organizationId,
|
||||
rbacRoleId,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
rbacRoleId: string;
|
||||
}) {
|
||||
try {
|
||||
const roleResult = await rbac.setUserRole({
|
||||
userId,
|
||||
organizationId,
|
||||
roleId: rbacRoleId,
|
||||
});
|
||||
if (!roleResult.ok) {
|
||||
logger.error("acceptInvite: skipped RBAC role assignment", {
|
||||
organizationId,
|
||||
userId,
|
||||
rbacRoleId,
|
||||
reason: roleResult.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("acceptInvite: RBAC role assignment threw", {
|
||||
organizationId,
|
||||
userId,
|
||||
rbacRoleId,
|
||||
error:
|
||||
error instanceof Error
|
||||
? { name: error.name, message: error.message, stack: error.stack }
|
||||
: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tryRecoverIncompleteInviteAccept({
|
||||
user,
|
||||
organizationId,
|
||||
inviteId,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
organizationId: string;
|
||||
inviteId: string;
|
||||
}) {
|
||||
const member = await prisma.orgMember.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
organization: { deletedAt: null },
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
projects: { where: { deletedAt: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const missingProjects = await getProjectsMissingMemberDevelopmentEnvironments({
|
||||
memberId: member.id,
|
||||
organizationId,
|
||||
projects: member.organization.projects,
|
||||
});
|
||||
|
||||
if (missingProjects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
|
||||
organizationId,
|
||||
"DEVELOPMENT"
|
||||
);
|
||||
|
||||
await provisionMemberDevelopmentEnvironments({
|
||||
inviteId,
|
||||
user,
|
||||
member,
|
||||
organization: member.organization,
|
||||
projects: missingProjects,
|
||||
maximumConcurrencyLimit,
|
||||
});
|
||||
|
||||
return {
|
||||
remainingInvites: await getUsersInvites({ email: user.email }),
|
||||
organization: member.organization,
|
||||
};
|
||||
}
|
||||
|
||||
export async function acceptInvite({
|
||||
user,
|
||||
inviteId,
|
||||
organizationId,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
inviteId: string;
|
||||
organizationId?: string;
|
||||
}) {
|
||||
const invite = await prisma.orgMemberInvite.findFirst({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
projects: { where: { deletedAt: null } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
if (organizationId) {
|
||||
const recovered = await tryRecoverIncompleteInviteAccept({
|
||||
user,
|
||||
organizationId,
|
||||
inviteId,
|
||||
});
|
||||
if (recovered) {
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
throw new Error(INVITE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Directory-managed membership: accepting an invite would add a member
|
||||
// outside the directory. Block it (the invite can still be revoked by an
|
||||
// admin). Fail-open on a plugin error so a hiccup doesn't strand joiners.
|
||||
const membershipPolicy = await ssoController.getMembershipPolicy(invite.organizationId);
|
||||
if (membershipPolicy.isOk() && !membershipPolicy.value.manualMembershipAllowed) {
|
||||
throw new Error(INVITE_BLOCKED_DIRECTORY_MANAGED);
|
||||
}
|
||||
|
||||
const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
|
||||
invite.organizationId,
|
||||
"DEVELOPMENT"
|
||||
);
|
||||
|
||||
let member = await prisma.orgMember.findFirst({
|
||||
where: {
|
||||
organizationId: invite.organizationId,
|
||||
userId: user.id,
|
||||
organization: { deletedAt: null },
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
try {
|
||||
member = await prisma.orgMember.create({
|
||||
data: {
|
||||
organizationId: invite.organizationId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
) {
|
||||
member = await prisma.orgMember.findFirst({
|
||||
where: {
|
||||
organizationId: invite.organizationId,
|
||||
userId: user.id,
|
||||
organization: { deletedAt: null },
|
||||
},
|
||||
});
|
||||
if (!member) {
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await provisionMemberDevelopmentEnvironments({
|
||||
inviteId,
|
||||
user,
|
||||
member,
|
||||
organization: invite.organization,
|
||||
projects: invite.organization.projects,
|
||||
maximumConcurrencyLimit,
|
||||
});
|
||||
|
||||
// Consume the invite only after development environments are provisioned so
|
||||
// a failed setup can be retried from /invites.
|
||||
try {
|
||||
await prisma.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof PrismaNamespace.PrismaClientKnownRequestError && error.code === "P2025")
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const remainingInvites = await getUsersInvites({ email: user.email });
|
||||
|
||||
// If the invite carried an explicit RBAC role, assign it. Best-effort: the
|
||||
// invite is already consumed and membership created above, so a failure here
|
||||
// — a returned {ok:false} or a thrown error from the plugin — must not block
|
||||
// joining the org. Swallow and log either way; without the catch a plugin
|
||||
// throw escapes and turns the whole invite-accept into a 400.
|
||||
if (invite.rbacRoleId) {
|
||||
await assignInviteRbacRole({
|
||||
userId: user.id,
|
||||
organizationId: invite.organization.id,
|
||||
rbacRoleId: invite.rbacRoleId,
|
||||
});
|
||||
}
|
||||
|
||||
// Deliberate re-admission clears any sticky-removal tombstone so this
|
||||
// membership isn't shadowed by a prior removal (best-effort; no-op in OSS).
|
||||
await ssoController
|
||||
.clearMembershipRemoval({ organizationId: invite.organization.id, userId: user.id })
|
||||
.unwrapOr(undefined);
|
||||
|
||||
return { remainingInvites, organization: invite.organization };
|
||||
}
|
||||
|
||||
export async function declineInvite({
|
||||
user,
|
||||
inviteId,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
inviteId: string;
|
||||
}) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
//1. delete invite
|
||||
const declinedInvite = await tx.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
//2. check for other invites
|
||||
const remainingInvites = await tx.orgMemberInvite.findMany({
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
return { remainingInvites, organization: declinedInvite.organization };
|
||||
});
|
||||
}
|
||||
|
||||
export async function resendInvite({ inviteId, userId }: { inviteId: string; userId: string }) {
|
||||
return await prisma.orgMemberInvite.update({
|
||||
where: {
|
||||
id: inviteId,
|
||||
inviterId: userId,
|
||||
},
|
||||
data: {
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
inviter: true,
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeInvite({
|
||||
userId,
|
||||
orgSlug,
|
||||
inviteId,
|
||||
}: {
|
||||
userId: string;
|
||||
orgSlug: string;
|
||||
inviteId: string;
|
||||
}) {
|
||||
const invite = await prisma.orgMemberInvite.findFirst({
|
||||
where: {
|
||||
id: inviteId,
|
||||
organization: {
|
||||
slug: orgSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
throw new Error("Invite not found");
|
||||
}
|
||||
|
||||
await prisma.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { email: invite.email, organization: invite.organization };
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { json, createCookieSessionStorage, type Session } from "@remix-run/node";
|
||||
import { redirect, typedjson } from "remix-typedjson";
|
||||
import type { ButtonVariant } from "~/components/primitives/Buttons";
|
||||
import { env } from "~/env.server";
|
||||
import { type FeedbackType } from "~/routes/resources.feedback";
|
||||
|
||||
export type ToastMessage = {
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
options: Required<ToastMessageOptions>;
|
||||
};
|
||||
|
||||
export type ToastMessageAction = {
|
||||
label: string;
|
||||
variant?: ButtonVariant;
|
||||
action:
|
||||
| {
|
||||
type: "link";
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
type: "help";
|
||||
feedbackType: FeedbackType;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToastMessageOptions = {
|
||||
title?: string;
|
||||
/** Ephemeral means it disappears after a delay, defaults to true */
|
||||
ephemeral?: boolean;
|
||||
/** This display a button and make it not ephemeral, unless ephemeral is explicitlyset to false */
|
||||
action?: ToastMessageAction;
|
||||
};
|
||||
|
||||
const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
|
||||
|
||||
// Clamp in UTF-8 bytes (not code units) so a flashed toast can never overflow the ~4KB
|
||||
// `__message` cookie and 500 in commitSession, even for multibyte messages.
|
||||
const MAX_TOAST_MESSAGE_BYTES = 1024;
|
||||
const toastMessageEncoder = new TextEncoder();
|
||||
|
||||
function clampToastMessage(message: string) {
|
||||
if (toastMessageEncoder.encode(message).length <= MAX_TOAST_MESSAGE_BYTES) {
|
||||
return message;
|
||||
}
|
||||
const budget = MAX_TOAST_MESSAGE_BYTES - 3; // leave room for the "..." suffix
|
||||
let bytes = 0;
|
||||
let truncated = "";
|
||||
for (const char of message) {
|
||||
const charBytes = toastMessageEncoder.encode(char).length;
|
||||
if (bytes + charBytes > budget) {
|
||||
break;
|
||||
}
|
||||
bytes += charBytes;
|
||||
truncated += char;
|
||||
}
|
||||
return `${truncated}...`;
|
||||
}
|
||||
|
||||
export const { commitSession, getSession } = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__message",
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secrets: [env.SESSION_SECRET],
|
||||
secure: env.NODE_ENV === "production",
|
||||
},
|
||||
});
|
||||
|
||||
export function setSuccessMessage(
|
||||
session: Session,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
session.flash("toastMessage", {
|
||||
message: clampToastMessage(message),
|
||||
type: "success",
|
||||
options: {
|
||||
...options,
|
||||
ephemeral: options?.ephemeral ?? true,
|
||||
},
|
||||
} as ToastMessage);
|
||||
}
|
||||
|
||||
export function setErrorMessage(session: Session, message: string, options?: ToastMessageOptions) {
|
||||
session.flash("toastMessage", {
|
||||
message: clampToastMessage(message),
|
||||
type: "error",
|
||||
options: {
|
||||
...options,
|
||||
ephemeral: options?.ephemeral ?? true,
|
||||
},
|
||||
} as ToastMessage);
|
||||
}
|
||||
|
||||
export async function setRequestErrorMessage(
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setErrorMessage(session, message, options);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function setRequestSuccessMessage(
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setSuccessMessage(session, message, options);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function setToastMessageCookie(session: Session) {
|
||||
return {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function jsonWithSuccessMessage(
|
||||
data: any,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setSuccessMessage(session, message, options);
|
||||
|
||||
return json(data, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function jsonWithErrorMessage(
|
||||
data: any,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setErrorMessage(session, message, options);
|
||||
|
||||
return json(data, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function typedJsonWithSuccessMessage<T>(
|
||||
data: T,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setSuccessMessage(session, message, options);
|
||||
|
||||
return typedjson(data, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function typedJsonWithErrorMessage<T>(
|
||||
data: T,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setErrorMessage(session, message, options);
|
||||
|
||||
return typedjson(data, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function redirectWithSuccessMessage(
|
||||
path: string,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setSuccessMessage(session, message, options);
|
||||
|
||||
return redirect(path, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function redirectWithErrorMessage(
|
||||
path: string,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setErrorMessage(session, message, options);
|
||||
|
||||
return redirect(path, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function redirectBackWithErrorMessage(
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
return redirectWithErrorMessage(url.pathname, request, message, options);
|
||||
}
|
||||
|
||||
export async function redirectBackWithSuccessMessage(
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
return redirectWithSuccessMessage(url.pathname, request, message, options);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import type {
|
||||
IntegrationService,
|
||||
Organization,
|
||||
OrganizationIntegration,
|
||||
SecretReference,
|
||||
} from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { slackSecretLogFields } from "./safeIntegrationLog";
|
||||
import { slackAccessResultLogFields } from "./slackOAuthResultLog";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
|
||||
const SlackSecretSchema = z.object({
|
||||
botAccessToken: z.string(),
|
||||
userAccessToken: z.string().optional(),
|
||||
expiresIn: z.number().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
botScopes: z.array(z.string()).optional(),
|
||||
userScopes: z.array(z.string()).optional(),
|
||||
raw: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
type SlackSecret = z.infer<typeof SlackSecretSchema>;
|
||||
|
||||
const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth";
|
||||
|
||||
export type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
|
||||
AuthenticatableIntegration,
|
||||
"service"
|
||||
> & {
|
||||
service: TService;
|
||||
};
|
||||
|
||||
type AuthenticatedClientOptions<TService extends IntegrationService> = TService extends "SLACK"
|
||||
? {
|
||||
forceBotToken?: boolean;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
type AuthenticatedClientForIntegration<TService extends IntegrationService> =
|
||||
TService extends "SLACK" ? InstanceType<typeof WebClient> : never;
|
||||
|
||||
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>,
|
||||
options?: AuthenticatedClientOptions<TService>
|
||||
): Promise<AuthenticatedClientForIntegration<TService>> {
|
||||
const secretStore = getSecretStore(integration.tokenReference.provider);
|
||||
|
||||
switch (integration.service) {
|
||||
case "SLACK": {
|
||||
const secret = await secretStore.getSecret(
|
||||
SlackSecretSchema,
|
||||
integration.tokenReference.key
|
||||
);
|
||||
|
||||
if (!secret) {
|
||||
throw new Error("Failed to get access token");
|
||||
}
|
||||
|
||||
// TODO refresh access token here
|
||||
return new WebClient(
|
||||
options?.forceBotToken
|
||||
? secret.botAccessToken
|
||||
: (secret.userAccessToken ?? secret.botAccessToken),
|
||||
{
|
||||
retryConfig: {
|
||||
retries: 2,
|
||||
randomize: true,
|
||||
maxTimeout: 5000,
|
||||
maxRetryTime: 10000,
|
||||
},
|
||||
}
|
||||
) as AuthenticatedClientForIntegration<TService>;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported service ${integration.service}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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[] = [
|
||||
"channels:read",
|
||||
"groups:read",
|
||||
"im:read",
|
||||
"mpim:read",
|
||||
"chat:write",
|
||||
"chat:write.public",
|
||||
],
|
||||
userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"]
|
||||
) {
|
||||
return `https://slack.com/oauth/v2/authorize?client_id=${
|
||||
env.ORG_SLACK_INTEGRATION_CLIENT_ID
|
||||
}&scope=${scopes.join(",")}&user_scope=${userScopes.join(",")}&state=${state}&redirect_uri=${
|
||||
env.APP_ORIGIN
|
||||
}/integrations/slack/callback`;
|
||||
}
|
||||
|
||||
static async redirectToAuthService(
|
||||
service: IntegrationService,
|
||||
state: string,
|
||||
request: Request,
|
||||
redirectTo: string
|
||||
) {
|
||||
const session = await getUserSession(request);
|
||||
session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo);
|
||||
|
||||
const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined;
|
||||
|
||||
if (!authUrl) {
|
||||
throw new Response("Unsupported service", { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("Redirecting to auth service", {
|
||||
service,
|
||||
authUrl,
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: authUrl,
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async redirectAfterAuth(request: Request) {
|
||||
const session = await getUserSession(request);
|
||||
|
||||
logger.debug("Redirecting back after auth", {
|
||||
sessionData: session.data,
|
||||
});
|
||||
|
||||
const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY);
|
||||
|
||||
if (!redirectTo) {
|
||||
throw new Response("Invalid redirect", { status: 400 });
|
||||
}
|
||||
|
||||
session.unset(REDIRECT_AFTER_AUTH_KEY);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: redirectTo,
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async createOrgIntegration(serviceName: string, code: string, org: Organization) {
|
||||
switch (serviceName) {
|
||||
case "slack": {
|
||||
if (!env.ORG_SLACK_INTEGRATION_CLIENT_ID || !env.ORG_SLACK_INTEGRATION_CLIENT_SECRET) {
|
||||
throw new Error("Slack integration not configured");
|
||||
}
|
||||
|
||||
const client = new WebClient();
|
||||
|
||||
const result = await client.oauth.v2.access({
|
||||
client_id: env.ORG_SLACK_INTEGRATION_CLIENT_ID,
|
||||
client_secret: env.ORG_SLACK_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: `${env.APP_ORIGIN}/integrations/slack/callback`,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
// `result` carries Slack tokens; log only non-secret diagnostics.
|
||||
logger.debug("Received slack access token", slackAccessResultLogFields(result));
|
||||
|
||||
if (!result.access_token) {
|
||||
throw new Error("Failed to get access token");
|
||||
}
|
||||
|
||||
return await $transaction(prisma, async (tx) => {
|
||||
const secretStore = getSecretStore("DATABASE", {
|
||||
prismaClient: tx,
|
||||
});
|
||||
|
||||
const integrationFriendlyId = generateFriendlyId("org_integration");
|
||||
|
||||
const secretValue: SlackSecret = {
|
||||
botAccessToken: result.access_token!,
|
||||
userAccessToken: result.authed_user ? result.authed_user.access_token : undefined,
|
||||
expiresIn: result.expires_in,
|
||||
refreshToken: result.refresh_token,
|
||||
botScopes: result.scope ? result.scope.split(",") : [],
|
||||
userScopes: result.authed_user?.scope ? result.authed_user.scope.split(",") : [],
|
||||
raw: result,
|
||||
};
|
||||
|
||||
// `secretValue` carries the tokens encrypted below; log only
|
||||
// non-secret fields.
|
||||
logger.debug(
|
||||
"Setting secret",
|
||||
slackSecretLogFields(integrationFriendlyId, secretValue)
|
||||
);
|
||||
|
||||
await secretStore.setSecret(integrationFriendlyId, secretValue);
|
||||
|
||||
const reference = await tx.secretReference.create({
|
||||
data: {
|
||||
provider: "DATABASE",
|
||||
key: integrationFriendlyId,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.organizationIntegration.create({
|
||||
data: {
|
||||
friendlyId: integrationFriendlyId,
|
||||
organizationId: org.id,
|
||||
service: "SLACK",
|
||||
tokenReferenceId: reference.id,
|
||||
integrationData: {
|
||||
team: result.team,
|
||||
user: result.authed_user
|
||||
? {
|
||||
id: result.authed_user.id,
|
||||
}
|
||||
: undefined,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Service ${serviceName} not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { Prisma, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import {
|
||||
getValidPersonalAccessTokens,
|
||||
revokePersonalAccessToken,
|
||||
} from "~/services/personalAccessToken.server";
|
||||
|
||||
export type EnsureOrgMemberParams = {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
// null = use the seeded MEMBER role from the existing enum. A non-null
|
||||
// value is an RBAC role id; when an RBAC plugin is installed it gets
|
||||
// attached after the OrgMember row is created.
|
||||
roleId: string | null;
|
||||
source: "sso_jit" | "invite" | "manual" | "directory_sync";
|
||||
};
|
||||
|
||||
export type EnsureOrgMemberResult = { created: boolean; orgMemberId: string };
|
||||
|
||||
// Completes a JIT role assignment for an ALREADY-existing membership whose
|
||||
// RBAC role never got applied. This is a no-op when a role is already
|
||||
// assigned, so it can never demote a deliberately-set role — it only fills
|
||||
// in the gap left by an interrupted provision (see `ensureOrgMember`). Always
|
||||
// best-effort: a valid membership already exists, so a failure here is logged
|
||||
// and swallowed rather than thrown.
|
||||
async function healMissingRoleAssignment(params: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
roleId: string;
|
||||
source: EnsureOrgMemberParams["source"];
|
||||
}): Promise<void> {
|
||||
const { userId, organizationId, roleId, source } = params;
|
||||
|
||||
const currentRole = await rbac.getUserRole({ userId, organizationId });
|
||||
if (currentRole !== null) return;
|
||||
|
||||
const result = await rbac.setUserRole({ userId, organizationId, roleId });
|
||||
if (!result.ok) {
|
||||
logger.warn("ensureOrgMember.setUserRole failed while healing unassigned membership", {
|
||||
source,
|
||||
userId,
|
||||
organizationId,
|
||||
roleId,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent OrgMember upsert. If the (userId, organizationId) row
|
||||
// already exists this is a no-op (returns `{ created: false }`); we do
|
||||
// NOT touch the existing role to avoid demoting a user that JIT happens
|
||||
// to fire for again.
|
||||
//
|
||||
// Seat-limit enforcement lives at the call sites — every existing
|
||||
// OrgMember insert in the codebase does its own seat check before
|
||||
// calling in. This helper deliberately does none (SSO JIT and
|
||||
// invite-accept are exempt by policy).
|
||||
export async function ensureOrgMember(
|
||||
params: EnsureOrgMemberParams
|
||||
): Promise<EnsureOrgMemberResult> {
|
||||
const { userId, organizationId, roleId, source } = params;
|
||||
|
||||
const existing = await prisma.orgMember.findFirst({
|
||||
where: { userId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
// Existing membership is normally a pure no-op: we don't re-touch the
|
||||
// role, since a user JIT fires for again may have been deliberately
|
||||
// promoted and must not be demoted back to the JIT default.
|
||||
//
|
||||
// The one exception is self-healing a half-provisioned row. The create +
|
||||
// setUserRole + compensating delete below are not transactional (the RBAC
|
||||
// plugin writes on its own connection, so a single DB transaction isn't
|
||||
// possible). If setUserRole failed AND that compensating delete also
|
||||
// failed, the placeholder MEMBER row is orphaned — and this findFirst
|
||||
// would short-circuit every future login, stranding the user on the
|
||||
// placeholder role forever. So when a JIT role is requested but the RBAC
|
||||
// layer shows no role assigned, complete the assignment now. It's gated on
|
||||
// "no role assigned", so it can never demote a real one.
|
||||
if (roleId !== null) {
|
||||
await healMissingRoleAssignment({ userId, organizationId, roleId, source });
|
||||
}
|
||||
return { created: false, orgMemberId: existing.id };
|
||||
}
|
||||
|
||||
// Two concurrent JIT/invite flows can both miss the findFirst above and
|
||||
// race to create the same (userId, organizationId) row; the unique
|
||||
// constraint makes one lose with P2002. Treat that as the idempotent
|
||||
// "already a member" case rather than letting it break sign-in.
|
||||
let member: { id: string };
|
||||
try {
|
||||
member = await prisma.orgMember.create({
|
||||
data: {
|
||||
userId,
|
||||
organizationId,
|
||||
role: "MEMBER",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
const existingAfterConflict = await prisma.orgMember.findFirst({
|
||||
where: { userId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingAfterConflict) {
|
||||
return { created: false, orgMemberId: existingAfterConflict.id };
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (roleId !== null) {
|
||||
const result = await rbac.setUserRole({ userId, organizationId, roleId });
|
||||
if (!result.ok) {
|
||||
// The membership was just created with the legacy `MEMBER` enum role as
|
||||
// a placeholder; the intended RBAC role failed to apply. Leaving the row
|
||||
// in place would grant the user `MEMBER` access — potentially broader
|
||||
// than the configured (e.g. restrictive) JIT default role they were
|
||||
// supposed to get. Roll back so we never half-provision into an
|
||||
// unintended privilege level, then throw so the caller can decide
|
||||
// whether to skip provisioning or fail the flow.
|
||||
logger.warn("ensureOrgMember.setUserRole failed; rolling back membership", {
|
||||
source,
|
||||
userId,
|
||||
organizationId,
|
||||
roleId,
|
||||
error: result.error,
|
||||
});
|
||||
await prisma.orgMember.delete({ where: { id: member.id } });
|
||||
throw new Error(`ensureOrgMember: failed to apply role ${roleId}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { created: true, orgMemberId: member.id };
|
||||
}
|
||||
|
||||
// Find-or-create a User for a directory-provisioned member. Directory Sync
|
||||
// can provision a user before they have ever logged in, so the User row may
|
||||
// not exist yet. Email is the natural key (lowercased). New rows are marked
|
||||
// SSO since the user will authenticate via the org's IdP.
|
||||
export async function ensureUserForDirectory(params: {
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
}): Promise<{ userId: string }> {
|
||||
const email = params.email.toLowerCase().trim();
|
||||
const existing = await prisma.user.findFirst({ where: { email }, select: { id: true } });
|
||||
if (existing) return { userId: existing.id };
|
||||
|
||||
const name = [params.firstName, params.lastName].filter(Boolean).join(" ").trim() || null;
|
||||
// `User.email` is unique, so two concurrent directory events for the same
|
||||
// email can both miss the lookup above and race on create; the loser gets
|
||||
// P2002. Treat that as the idempotent "already exists" case (same pattern as
|
||||
// `ensureOrgMember`) rather than throwing and burning a webhook retry.
|
||||
try {
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
authenticationMethod: "SSO",
|
||||
name,
|
||||
displayName: name,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return { userId: created.id };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
const existingAfterConflict = await prisma.user.findFirst({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingAfterConflict) return { userId: existingAfterConflict.id };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the user holds the Owner system role in this org. Owner is the one
|
||||
// role Directory Sync must never strip (it can't be auto-granted and is the
|
||||
// org's recovery anchor), so deprovision is guarded against removing the last
|
||||
// one. Identified by the RBAC system role; OSS-safe (no plugin → not Owner).
|
||||
function isOwnerRole(role: { name: string; isSystem: boolean } | null): boolean {
|
||||
return !!role && role.isSystem && role.name === "Owner";
|
||||
}
|
||||
|
||||
export type RemoveOrgMemberForDirectoryResult =
|
||||
| { removed: true }
|
||||
| { removed: false; reason: "not_a_member" | "last_owner_protected" };
|
||||
|
||||
// Deprovision a directory-removed user from an org: hard-delete the
|
||||
// OrgMember, drop the RBAC role, force-logout (nextSessionEnd), and revoke
|
||||
// the user's personal access tokens ONLY when this was their last org (PATs
|
||||
// are user-global, so revoking on a single-org removal would break their CLI
|
||||
// access to other orgs). Refuses to remove the org's last Owner.
|
||||
export async function removeOrgMemberForDirectory(params: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
}): Promise<RemoveOrgMemberForDirectoryResult> {
|
||||
const { userId, organizationId } = params;
|
||||
|
||||
const member = await prisma.orgMember.findFirst({
|
||||
where: { userId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!member) return { removed: false, reason: "not_a_member" };
|
||||
|
||||
// Last-Owner guard: never leave the org without an Owner. Resolve every
|
||||
// member's RBAC role and bail if this user is the only Owner.
|
||||
const members = await prisma.orgMember.findMany({
|
||||
where: { organizationId },
|
||||
select: { userId: true },
|
||||
});
|
||||
const roles = await rbac.getUserRoles(
|
||||
members.map((m) => m.userId),
|
||||
organizationId
|
||||
);
|
||||
if (isOwnerRole(roles.get(userId) ?? null)) {
|
||||
const otherOwners = members.filter(
|
||||
(m) => m.userId !== userId && isOwnerRole(roles.get(m.userId) ?? null)
|
||||
);
|
||||
if (otherOwners.length === 0) {
|
||||
logger.warn("removeOrgMemberForDirectory: refusing to remove last Owner", {
|
||||
userId,
|
||||
organizationId,
|
||||
});
|
||||
return { removed: false, reason: "last_owner_protected" };
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.orgMember.delete({ where: { id: member.id } });
|
||||
const removeRole = await rbac.removeUserRole({ userId, organizationId });
|
||||
if (!removeRole.ok) {
|
||||
logger.warn("removeOrgMemberForDirectory: failed to remove RBAC role", {
|
||||
userId,
|
||||
organizationId,
|
||||
error: removeRole.error,
|
||||
});
|
||||
}
|
||||
|
||||
// Post-delete cleanup is best-effort: the membership (the critical state) is
|
||||
// already gone, so any throw here must not propagate. If it did, the webhook
|
||||
// worker would retry, hit the `not_a_member` guard above, and skip the rest
|
||||
// of the cleanup entirely — leaving sessions or PATs behind. Swallowing lets
|
||||
// this single pass finish force-logout + PAT revocation.
|
||||
|
||||
// Force logout everywhere.
|
||||
try {
|
||||
await prisma.user.update({ where: { id: userId }, data: { nextSessionEnd: new Date() } });
|
||||
} catch (error) {
|
||||
logger.warn("removeOrgMemberForDirectory: failed to force logout", {
|
||||
userId,
|
||||
organizationId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
// Revoke PATs only if the user no longer belongs to ANY org — PATs are
|
||||
// user-global and used by the CLI across every org the user is in. Each
|
||||
// revoke is guarded so a concurrent self-revoke (which would throw) or one
|
||||
// bad token doesn't abort the rest.
|
||||
try {
|
||||
const remainingMemberships = await prisma.orgMember.count({ where: { userId } });
|
||||
if (remainingMemberships === 0) {
|
||||
const tokens = await getValidPersonalAccessTokens(userId);
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
await revokePersonalAccessToken(token.id, userId);
|
||||
} catch (error) {
|
||||
logger.warn("removeOrgMemberForDirectory: failed to revoke PAT", {
|
||||
userId,
|
||||
tokenId: token.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("removeOrgMemberForDirectory: PAT cleanup failed", {
|
||||
userId,
|
||||
organizationId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return { removed: true };
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
Organization,
|
||||
OrgMember,
|
||||
Prisma,
|
||||
Project,
|
||||
RuntimeEnvironment,
|
||||
User,
|
||||
} from "@trigger.dev/database";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { generate } from "random-words";
|
||||
import slug from "slug";
|
||||
import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { featuresForUrl } from "~/features.server";
|
||||
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
|
||||
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
|
||||
import { enqueueAttioWorkspaceSync } from "~/services/attio.server";
|
||||
import {
|
||||
applyBillingLimitPauseAfterEnvCreate,
|
||||
getInitialEnvPauseStateForBillingLimit,
|
||||
} from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server";
|
||||
export type { Organization };
|
||||
|
||||
const nanoid = customAlphabet("1234567890abcdef", 4);
|
||||
|
||||
/**
|
||||
* Resolve an organization id from its slug for use as an RBAC auth scope.
|
||||
* Reads the replica first (the common case) and falls back to the primary on a
|
||||
* miss, so replica lag never leaves a real org unresolved, which the dashboard
|
||||
* route builder treats as an unauthorized request.
|
||||
*/
|
||||
export async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
|
||||
const fromReplica = await $replica.organization.findFirst({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
if (fromReplica) {
|
||||
return fromReplica.id;
|
||||
}
|
||||
|
||||
const fromPrimary = await prisma.organization.findFirst({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
return fromPrimary?.id ?? null;
|
||||
}
|
||||
|
||||
export async function createOrganization(
|
||||
{
|
||||
title,
|
||||
userId,
|
||||
companySize,
|
||||
onboardingData,
|
||||
avatar,
|
||||
}: Pick<Organization, "title" | "companySize"> & {
|
||||
userId: User["id"];
|
||||
onboardingData?: Prisma.InputJsonValue;
|
||||
avatar?: Prisma.InputJsonValue;
|
||||
},
|
||||
attemptCount = 0
|
||||
): Promise<Organization> {
|
||||
if (typeof process.env.BLOCKED_USERS === "string" && process.env.BLOCKED_USERS.includes(userId)) {
|
||||
throw new Error("Organization could not be created.");
|
||||
}
|
||||
|
||||
const uniqueOrgSlug = `${slug(title)}-${nanoid(4)}`;
|
||||
|
||||
const orgWithSameSlug = await prisma.organization.findFirst({
|
||||
where: { slug: uniqueOrgSlug },
|
||||
});
|
||||
|
||||
if (attemptCount > 100) {
|
||||
throw new Error(`Unable to create organization with slug ${uniqueOrgSlug} after 100 attempts`);
|
||||
}
|
||||
|
||||
if (orgWithSameSlug) {
|
||||
return createOrganization(
|
||||
{
|
||||
title,
|
||||
userId,
|
||||
companySize,
|
||||
onboardingData,
|
||||
avatar,
|
||||
},
|
||||
attemptCount + 1
|
||||
);
|
||||
}
|
||||
|
||||
const features = featuresForUrl(new URL(env.APP_ORIGIN));
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
title,
|
||||
slug: uniqueOrgSlug,
|
||||
companySize,
|
||||
onboardingData: onboardingData ?? undefined,
|
||||
avatar: avatar ?? undefined,
|
||||
maximumConcurrencyLimit: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
|
||||
members: {
|
||||
create: {
|
||||
userId: userId,
|
||||
role: "ADMIN",
|
||||
},
|
||||
},
|
||||
// Managed-cloud orgs start deactivated so they're routed through
|
||||
// select-plan, which provisions their billing entitlement and activates
|
||||
// them. Self-hosters have no billing gate, so they're active immediately.
|
||||
isActivated: !features.isManagedCloud,
|
||||
},
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fire-and-forget; never blocks org creation.
|
||||
void enqueueAttioWorkspaceSync({
|
||||
orgId: organization.id,
|
||||
title: organization.title,
|
||||
slug: organization.slug,
|
||||
companySize: organization.companySize,
|
||||
createdAt: organization.createdAt,
|
||||
adminUserId: userId,
|
||||
});
|
||||
|
||||
return { ...organization };
|
||||
}
|
||||
|
||||
export async function createEnvironment({
|
||||
organization,
|
||||
project,
|
||||
type,
|
||||
isBranchableEnvironment = false,
|
||||
member,
|
||||
prismaClient = prisma,
|
||||
/** When set, skips billing lookup — caller must supply the limit for this org + type. */
|
||||
maximumConcurrencyLimit,
|
||||
}: {
|
||||
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
|
||||
project: Pick<Project, "id">;
|
||||
type: RuntimeEnvironment["type"];
|
||||
isBranchableEnvironment?: boolean;
|
||||
member?: OrgMember;
|
||||
prismaClient?: PrismaClientOrTransaction;
|
||||
maximumConcurrencyLimit?: number;
|
||||
}) {
|
||||
const slug = envSlug(type);
|
||||
const apiKey = createApiKeyForEnv(type);
|
||||
const pkApiKey = createPkApiKeyForEnv(type);
|
||||
const shortcode = createShortcode().join("-");
|
||||
|
||||
const limit =
|
||||
maximumConcurrencyLimit ?? (await getDefaultEnvironmentConcurrencyLimit(organization.id, type));
|
||||
const billingPause = await getInitialEnvPauseStateForBillingLimit(organization.id, type);
|
||||
|
||||
const environment = await prismaClient.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug,
|
||||
apiKey,
|
||||
pkApiKey,
|
||||
shortcode,
|
||||
autoEnableInternalSources: type !== "DEVELOPMENT",
|
||||
maximumConcurrencyLimit: limit,
|
||||
paused: billingPause.paused,
|
||||
pauseSource: billingPause.pauseSource,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
connect: {
|
||||
id: project.id,
|
||||
},
|
||||
},
|
||||
orgMember: member ? { connect: { id: member.id } } : undefined,
|
||||
type,
|
||||
isBranchableEnvironment,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
await applyBillingLimitPauseAfterEnvCreate(environment);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
function createShortcode() {
|
||||
return generate({ exactly: 2 });
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Prisma, Project } from "@trigger.dev/database";
|
||||
import { customAlphabet, nanoid } from "nanoid";
|
||||
import slug from "slug";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { projectCreated } from "~/services/projectCreated.server";
|
||||
import { type Organization, createEnvironment } from "./organization.server";
|
||||
export type { Project } from "@trigger.dev/database";
|
||||
|
||||
const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20);
|
||||
|
||||
type Options = {
|
||||
organizationSlug: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
version: "v2" | "v3";
|
||||
onboardingData?: Prisma.InputJsonValue;
|
||||
};
|
||||
|
||||
export class ExceededProjectLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ExceededProjectLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
{ organizationSlug, name, userId, version, onboardingData }: Options,
|
||||
attemptCount = 0
|
||||
): Promise<Project & { organization: Organization }> {
|
||||
//check the user has permissions to do this
|
||||
const organization = await prisma.organization.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
isActivated: true,
|
||||
maximumConcurrencyLimit: true,
|
||||
maximumProjectCount: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(
|
||||
`User ${userId} does not have permission to create a project in organization ${organizationSlug}`
|
||||
);
|
||||
}
|
||||
|
||||
if (version === "v3") {
|
||||
if (!organization.isActivated) {
|
||||
throw new Error(`Organization can't create v3 projects.`);
|
||||
}
|
||||
}
|
||||
|
||||
const projectCount = await prisma.project.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (projectCount >= organization.maximumProjectCount) {
|
||||
throw new ExceededProjectLimitError(
|
||||
`This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
|
||||
);
|
||||
}
|
||||
|
||||
//ensure the slug is globally unique
|
||||
const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
|
||||
const projectWithSameSlug = await prisma.project.findFirst({
|
||||
where: { slug: uniqueProjectSlug },
|
||||
});
|
||||
|
||||
if (attemptCount > 100) {
|
||||
throw new Error(`Unable to create project with slug ${uniqueProjectSlug} after 100 attempts`);
|
||||
}
|
||||
|
||||
if (projectWithSameSlug) {
|
||||
return createProject(
|
||||
{
|
||||
organizationSlug,
|
||||
name,
|
||||
userId,
|
||||
version,
|
||||
onboardingData,
|
||||
},
|
||||
attemptCount + 1
|
||||
);
|
||||
}
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name,
|
||||
slug: uniqueProjectSlug,
|
||||
organization: {
|
||||
connect: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
},
|
||||
externalRef: `proj_${externalRefGenerator()}`,
|
||||
version: version === "v3" ? "V3" : "V2",
|
||||
onboardingData,
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create the dev and prod environments
|
||||
await createEnvironment({
|
||||
organization,
|
||||
project,
|
||||
type: "PRODUCTION",
|
||||
isBranchableEnvironment: false,
|
||||
});
|
||||
|
||||
for (const member of project.organization.members) {
|
||||
await createEnvironment({
|
||||
organization,
|
||||
project,
|
||||
type: "DEVELOPMENT",
|
||||
// We set this true but no backfill (yet!?) so never used
|
||||
// for dev environments
|
||||
isBranchableEnvironment: true,
|
||||
member,
|
||||
});
|
||||
}
|
||||
|
||||
await projectCreated(organization, project);
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function findProjectBySlug(orgSlug: string, projectSlug: string, userId: string) {
|
||||
// Find the project scoped to the organization, making sure the user belongs to that org
|
||||
return await $replica.project.findFirst({
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
slug: orgSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findProjectByRef(externalRef: string, userId: string) {
|
||||
// Find the project scoped to the organization, making sure the user belongs to that org
|
||||
return await $replica.project.findFirst({
|
||||
where: {
|
||||
externalRef,
|
||||
organization: {
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import { EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server";
|
||||
|
||||
export const ProjectAlertWebhookProperties = z.object({
|
||||
secret: EncryptedSecretValueSchema,
|
||||
url: z.string(),
|
||||
version: z.string().optional().default("v1"),
|
||||
});
|
||||
|
||||
export type ProjectAlertWebhookProperties = z.infer<typeof ProjectAlertWebhookProperties>;
|
||||
|
||||
export const ProjectAlertEmailProperties = z.object({
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectAlertEmailProperties = z.infer<typeof ProjectAlertEmailProperties>;
|
||||
|
||||
export const DeleteProjectAlertChannel = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectAlertSlackProperties = z.object({
|
||||
channelId: z.string(),
|
||||
channelName: z.string(),
|
||||
integrationId: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type ProjectAlertSlackProperties = z.infer<typeof ProjectAlertSlackProperties>;
|
||||
|
||||
export const ProjectAlertSlackStorage = z.object({
|
||||
message_ts: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectAlertSlackStorage = z.infer<typeof ProjectAlertSlackStorage>;
|
||||
|
||||
export const ErrorAlertConfig = z.object({
|
||||
evaluationIntervalMs: z.number().min(60_000).default(300_000),
|
||||
});
|
||||
|
||||
export type ErrorAlertConfig = z.infer<typeof ErrorAlertConfig>;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
|
||||
// Leaf module with a type-only Prisma import (caller passes the client) so it
|
||||
// can be unit-tested without importing `~/db.server`, which eagerly connects
|
||||
// the global prisma singleton.
|
||||
export async function removeTeamMember(
|
||||
{
|
||||
userId,
|
||||
slug,
|
||||
memberId,
|
||||
}: {
|
||||
userId: string;
|
||||
slug: string;
|
||||
memberId: string;
|
||||
},
|
||||
prismaClient: PrismaClient
|
||||
) {
|
||||
const org = await prismaClient.organization.findFirst({
|
||||
where: { slug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Error("User does not have access to this organization");
|
||||
}
|
||||
|
||||
// Scope both the lookup and the delete to org.id, in a transaction, so the
|
||||
// member id is only ever resolved within the actor's organization.
|
||||
return prismaClient.$transaction(async (tx) => {
|
||||
const target = await tx.orgMember.findFirst({
|
||||
where: { id: memberId, organizationId: org.id },
|
||||
include: { organization: true, user: true },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new Error("Member not found in this organization");
|
||||
}
|
||||
|
||||
await tx.orgMember.delete({ where: { id: target.id } });
|
||||
return target;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import type { AuthenticatedEnvironment } from "@internal/run-engine";
|
||||
import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
|
||||
|
||||
export type { RuntimeEnvironment };
|
||||
|
||||
// Prisma include shape that maps cleanly to the slim AuthenticatedEnvironment.
|
||||
// Use this everywhere we fetch an env that flows to handlers — keeps the
|
||||
// returned shape consistent (and the Decimal coercion in toAuthenticated()
|
||||
// strips Prisma's Decimal class from the public surface).
|
||||
export const authIncludeBase = {
|
||||
project: true,
|
||||
organization: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
user: { select: { id: true, displayName: true, name: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.RuntimeEnvironmentInclude;
|
||||
|
||||
export const authIncludeWithParent = {
|
||||
...authIncludeBase,
|
||||
parentEnvironment: { select: { id: true, apiKey: true } },
|
||||
} satisfies Prisma.RuntimeEnvironmentInclude;
|
||||
|
||||
type PrismaEnvWithAuth = Prisma.RuntimeEnvironmentGetPayload<{ include: typeof authIncludeBase }>;
|
||||
type PrismaEnvWithAuthAndParent = Prisma.RuntimeEnvironmentGetPayload<{
|
||||
include: typeof authIncludeWithParent;
|
||||
}>;
|
||||
|
||||
// Coerce a Prisma RuntimeEnvironment payload to the slim
|
||||
// AuthenticatedEnvironment shape. Drops the columns handlers don't read
|
||||
// and converts `concurrencyLimitBurstFactor` from Prisma's Decimal to a
|
||||
// plain number (lossless at this scale). The optional union accepts both
|
||||
// query shapes — with parentEnvironment loaded, or without it.
|
||||
export function toAuthenticated(
|
||||
env: PrismaEnvWithAuth | PrismaEnvWithAuthAndParent
|
||||
): AuthenticatedEnvironment {
|
||||
return {
|
||||
id: env.id,
|
||||
slug: env.slug,
|
||||
type: env.type,
|
||||
apiKey: env.apiKey,
|
||||
organizationId: env.organizationId,
|
||||
projectId: env.projectId,
|
||||
orgMemberId: env.orgMemberId,
|
||||
parentEnvironmentId: env.parentEnvironmentId,
|
||||
branchName: env.branchName,
|
||||
archivedAt: env.archivedAt,
|
||||
paused: env.paused,
|
||||
shortcode: env.shortcode,
|
||||
maximumConcurrencyLimit: env.maximumConcurrencyLimit,
|
||||
// Coerce Prisma's Decimal to a plain number — the slim type accepts
|
||||
// both, but downstream consumers shouldn't have to narrow before
|
||||
// doing arithmetic. Lossless at this scale (Decimal(4,2)).
|
||||
concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor.toNumber(),
|
||||
builtInEnvironmentVariableOverrides: env.builtInEnvironmentVariableOverrides,
|
||||
createdAt: env.createdAt,
|
||||
updatedAt: env.updatedAt,
|
||||
project: {
|
||||
id: env.project.id,
|
||||
slug: env.project.slug,
|
||||
name: env.project.name,
|
||||
externalRef: env.project.externalRef,
|
||||
engine: env.project.engine,
|
||||
deletedAt: env.project.deletedAt,
|
||||
defaultWorkerGroupId: env.project.defaultWorkerGroupId,
|
||||
organizationId: env.project.organizationId,
|
||||
builderProjectId: env.project.builderProjectId,
|
||||
},
|
||||
organization: {
|
||||
id: env.organization.id,
|
||||
slug: env.organization.slug,
|
||||
title: env.organization.title,
|
||||
streamBasinName: env.organization.streamBasinName,
|
||||
maximumConcurrencyLimit: env.organization.maximumConcurrencyLimit,
|
||||
runsEnabled: env.organization.runsEnabled,
|
||||
maximumDevQueueSize: env.organization.maximumDevQueueSize,
|
||||
maximumDeployedQueueSize: env.organization.maximumDeployedQueueSize,
|
||||
featureFlags: env.organization.featureFlags,
|
||||
apiRateLimiterConfig: env.organization.apiRateLimiterConfig,
|
||||
batchRateLimitConfig: env.organization.batchRateLimitConfig,
|
||||
batchQueueConcurrencyConfig: env.organization.batchQueueConcurrencyConfig,
|
||||
},
|
||||
orgMember: env.orgMember,
|
||||
parentEnvironment: "parentEnvironment" in env ? env.parentEnvironment : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findEnvironmentByApiKey(
|
||||
apiKey: string,
|
||||
branchName: string | undefined,
|
||||
tx: PrismaClientOrTransaction = $replica
|
||||
): Promise<AuthenticatedEnvironment | null> {
|
||||
const branch = sanitizeBranchName(branchName) ?? undefined;
|
||||
|
||||
const include = {
|
||||
...authIncludeBase,
|
||||
childEnvironments: branch
|
||||
? {
|
||||
where: {
|
||||
branchName: branch,
|
||||
archivedAt: null,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
} satisfies Prisma.RuntimeEnvironmentInclude;
|
||||
|
||||
let environment = await tx.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
apiKey,
|
||||
},
|
||||
include,
|
||||
});
|
||||
|
||||
// Fall back to keys that were revoked within the grace window
|
||||
if (!environment) {
|
||||
const revokedApiKey = await tx.revokedApiKey.findFirst({
|
||||
where: {
|
||||
apiKey,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
include: {
|
||||
runtimeEnvironment: { include },
|
||||
},
|
||||
});
|
||||
|
||||
environment = revokedApiKey?.runtimeEnvironment ?? null;
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
//don't return deleted projects
|
||||
if (environment.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (environment.type === "PREVIEW") {
|
||||
if (!branch) {
|
||||
logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", {
|
||||
environmentId: environment.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const childEnvironment = environment.childEnvironments.at(0);
|
||||
|
||||
if (childEnvironment) {
|
||||
return toAuthenticated({
|
||||
...childEnvironment,
|
||||
apiKey: environment.apiKey,
|
||||
orgMember: environment.orgMember,
|
||||
organization: environment.organization,
|
||||
project: environment.project,
|
||||
});
|
||||
}
|
||||
|
||||
//A branch was specified but no child environment was found
|
||||
return null;
|
||||
}
|
||||
|
||||
// If there is a named DEV branch (other than default), return it
|
||||
if (environment.type === "DEVELOPMENT" && branch !== undefined && !isDefaultDevBranch(branch)) {
|
||||
const childEnvironment = environment.childEnvironments.at(0);
|
||||
|
||||
if (childEnvironment) {
|
||||
return toAuthenticated({
|
||||
...childEnvironment,
|
||||
apiKey: environment.apiKey,
|
||||
orgMember: environment.orgMember,
|
||||
organization: environment.organization,
|
||||
project: environment.project,
|
||||
});
|
||||
}
|
||||
|
||||
//A branch was specified but no child environment was found
|
||||
return null;
|
||||
}
|
||||
|
||||
return toAuthenticated(environment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated We don't use public API keys (`pk_*` tokens) anymore — public
|
||||
* access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`).
|
||||
*
|
||||
* Still exported because a handful of pre-RBAC routes that haven't been
|
||||
* migrated to the apiBuilder still wire this lookup into their
|
||||
* `authenticateApiKey` / `authenticateApiKeyWithFailure` flow. The new RBAC
|
||||
* fallback (`internal-packages/rbac/src/fallback.ts`) intentionally does NOT
|
||||
* call this — any pk_*-authenticated request that hits an apiBuilder route
|
||||
* returns 401. That's a deliberate cutover, not an oversight.
|
||||
*/
|
||||
export async function findEnvironmentByPublicApiKey(
|
||||
apiKey: string,
|
||||
branchName: string | undefined
|
||||
): Promise<AuthenticatedEnvironment | null> {
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
pkApiKey: apiKey,
|
||||
},
|
||||
include: authIncludeBase,
|
||||
});
|
||||
|
||||
if (!environment || environment.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toAuthenticated(environment);
|
||||
}
|
||||
|
||||
export async function findEnvironmentById(id: string): Promise<AuthenticatedEnvironment | null> {
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: authIncludeWithParent,
|
||||
});
|
||||
|
||||
if (!environment || environment.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toAuthenticated(environment);
|
||||
}
|
||||
|
||||
export async function findEnvironmentBySlug(
|
||||
projectId: string,
|
||||
envSlug: string,
|
||||
userId: string
|
||||
): Promise<AuthenticatedEnvironment | null> {
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
projectId: projectId,
|
||||
slug: envSlug,
|
||||
OR: [
|
||||
{
|
||||
type: {
|
||||
in: ["PREVIEW", "STAGING", "PRODUCTION"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "DEVELOPMENT",
|
||||
orgMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: authIncludeBase,
|
||||
});
|
||||
return environment ? toAuthenticated(environment) : null;
|
||||
}
|
||||
|
||||
// The authenticated environment plus the run scalars the realtime publish needs.
|
||||
// Both come from one taskRun read — see findEnvironmentFromRun.
|
||||
export type EnvironmentFromRun = {
|
||||
environment: AuthenticatedEnvironment;
|
||||
runTags: string[];
|
||||
batchId: string | null;
|
||||
};
|
||||
|
||||
export async function findEnvironmentFromRun(
|
||||
runId: string,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<EnvironmentFromRun | null> {
|
||||
// Run-ops scalars (runTags/batchId/runtimeEnvironmentId) from the run store; the env half is
|
||||
// resolved via the control-plane resolver so the run-ops DB can split without a cross-DB join.
|
||||
const taskRun = await runStore.findRun(
|
||||
{
|
||||
id: runId,
|
||||
},
|
||||
{
|
||||
select: {
|
||||
runTags: true,
|
||||
batchId: true,
|
||||
runtimeEnvironmentId: true,
|
||||
},
|
||||
},
|
||||
tx ?? $replica
|
||||
);
|
||||
if (!taskRun) {
|
||||
return null;
|
||||
}
|
||||
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(
|
||||
taskRun.runtimeEnvironmentId
|
||||
);
|
||||
if (!environment) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
environment,
|
||||
runTags: taskRun.runTags,
|
||||
batchId: taskRun.batchId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createNewSession(
|
||||
environment: Pick<RuntimeEnvironment, "id">,
|
||||
ipAddress: string
|
||||
) {
|
||||
const session = await prisma.runtimeEnvironmentSession.create({
|
||||
data: {
|
||||
environmentId: environment.id,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.runtimeEnvironment.update({
|
||||
where: {
|
||||
id: environment.id,
|
||||
},
|
||||
data: {
|
||||
currentSessionId: session.id,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function disconnectSession(environmentId: string) {
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment || !environment.currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await prisma.runtimeEnvironmentSession.update({
|
||||
where: {
|
||||
id: environment.currentSessionId,
|
||||
},
|
||||
data: {
|
||||
disconnectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.runtimeEnvironment.update({
|
||||
where: {
|
||||
id: environment.id,
|
||||
},
|
||||
data: {
|
||||
currentSessionId: null,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function findLatestSession(
|
||||
environmentId: string,
|
||||
client: PrismaClientOrTransaction = $replica
|
||||
) {
|
||||
const session = await client.runtimeEnvironmentSession.findFirst({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export type DisplayableInputEnvironment = Prisma.RuntimeEnvironmentGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
type: true;
|
||||
slug: true;
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
displayName: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export function displayableEnvironment(
|
||||
environment: DisplayableInputEnvironment,
|
||||
userId: string | undefined
|
||||
) {
|
||||
let userName: string | undefined = undefined;
|
||||
|
||||
if (environment.type === "DEVELOPMENT") {
|
||||
if (!environment.orgMember) {
|
||||
userName = "Deleted";
|
||||
} else if (environment.orgMember.user.id !== userId) {
|
||||
userName = getUsername(environment.orgMember.user);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
slug: environment.slug,
|
||||
userName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findDisplayableEnvironment(
|
||||
environmentId: string,
|
||||
userId: string | undefined
|
||||
) {
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return;
|
||||
}
|
||||
|
||||
return displayableEnvironment(environment, userId);
|
||||
}
|
||||
|
||||
export async function hasAccessToEnvironment({
|
||||
environmentId,
|
||||
projectId,
|
||||
organizationId,
|
||||
userId,
|
||||
}: {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
}): Promise<boolean> {
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: environmentId,
|
||||
projectId: projectId,
|
||||
organizationId: organizationId,
|
||||
organization: { members: { some: { userId } } },
|
||||
},
|
||||
});
|
||||
|
||||
return environment !== null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Non-secret fields for logging a Slack integration secret: presence booleans
|
||||
// and scope arrays only, never the token values. Dependency-free so it's
|
||||
// unit-tested directly.
|
||||
export type SlackSecretLike = {
|
||||
botAccessToken?: string;
|
||||
userAccessToken?: string;
|
||||
refreshToken?: string;
|
||||
botScopes?: string[];
|
||||
userScopes?: string[];
|
||||
};
|
||||
|
||||
export function slackSecretLogFields(friendlyId: string, secret: SlackSecretLike) {
|
||||
return {
|
||||
friendlyId,
|
||||
hasUserToken: !!secret.userAccessToken,
|
||||
hasRefreshToken: !!secret.refreshToken,
|
||||
botScopes: secret.botScopes,
|
||||
userScopes: secret.userScopes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Prisma } from "~/db.server";
|
||||
|
||||
export function scheduleUniqWhereClause(
|
||||
projectId: string,
|
||||
scheduleId: string
|
||||
): Prisma.TaskScheduleWhereUniqueInput {
|
||||
if (scheduleId.startsWith("sched_")) {
|
||||
return {
|
||||
friendlyId: scheduleId,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
projectId_deduplicationKey: {
|
||||
projectId,
|
||||
deduplicationKey: scheduleId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function scheduleWhereClause(
|
||||
projectId: string,
|
||||
scheduleId: string
|
||||
): Prisma.TaskScheduleWhereInput {
|
||||
if (scheduleId.startsWith("sched_")) {
|
||||
return {
|
||||
friendlyId: scheduleId,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
deduplicationKey: scheduleId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Non-secret fields for logging a Slack `oauth.v2.access` response, which
|
||||
// otherwise carries bot/user/refresh tokens. Dependency-free so it's
|
||||
// unit-tested directly.
|
||||
export type SlackAccessResultLike = {
|
||||
team?: { id?: string } | null;
|
||||
scope?: string;
|
||||
authed_user?: { access_token?: string } | null;
|
||||
refresh_token?: string;
|
||||
};
|
||||
|
||||
export function slackAccessResultLogFields(result: SlackAccessResultLike) {
|
||||
return {
|
||||
teamId: result.team?.id,
|
||||
scope: result.scope,
|
||||
hasUserToken: !!result.authed_user?.access_token,
|
||||
hasRefreshToken: !!result.refresh_token,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import type { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
|
||||
export { getTaskIdentifiers } from "~/services/taskIdentifierRegistry.server";
|
||||
export type { TaskIdentifierEntry } from "~/services/taskIdentifierCache.server";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param prisma An efficient query to get all task identifiers for a project.
|
||||
* It has indexes for fast performance.
|
||||
* It does NOT care about versions, so includes all tasks ever created.
|
||||
*/
|
||||
export function getAllTaskIdentifiers(prisma: PrismaClientOrTransaction, environmentId: string) {
|
||||
return prisma.$queryRaw<
|
||||
{
|
||||
slug: string;
|
||||
triggerSource: TaskTriggerSource;
|
||||
}[]
|
||||
>`
|
||||
SELECT DISTINCT(slug), "triggerSource"
|
||||
FROM ${sqlDatabaseSchema}."BackgroundWorkerTask"
|
||||
WHERE "runtimeEnvironmentId" = ${environmentId}
|
||||
ORDER BY slug ASC;`;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { QueueManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import type { TaskQueue } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export async function findQueueInEnvironment(
|
||||
queueName: string,
|
||||
environmentId: string,
|
||||
backgroundWorkerTaskId?: string,
|
||||
backgroundTask?: { queueConfig?: unknown }
|
||||
): Promise<TaskQueue | undefined> {
|
||||
const sanitizedQueueName = sanitizeQueueName(queueName);
|
||||
|
||||
const queue = await prisma.taskQueue.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
name: sanitizedQueueName,
|
||||
},
|
||||
});
|
||||
|
||||
if (queue) {
|
||||
return queue;
|
||||
}
|
||||
|
||||
const task = backgroundTask
|
||||
? backgroundTask
|
||||
: backgroundWorkerTaskId
|
||||
? await prisma.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
id: backgroundWorkerTaskId,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queueConfig = QueueManifest.safeParse(task.queueConfig);
|
||||
|
||||
if (queueConfig.success) {
|
||||
const taskQueueName = queueConfig.data.name
|
||||
? sanitizeQueueName(queueConfig.data.name)
|
||||
: undefined;
|
||||
|
||||
if (taskQueueName && taskQueueName !== sanitizedQueueName) {
|
||||
const queue = await prisma.taskQueue.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
name: taskQueueName,
|
||||
},
|
||||
});
|
||||
|
||||
if (queue) {
|
||||
return queue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow alphanumeric characters, underscores, hyphens, and slashes (and only the first 128 characters)
|
||||
export function sanitizeQueueName(queueName: string) {
|
||||
return queueName.replace(/[^a-zA-Z0-9_\-/]/g, "").substring(0, 128);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
TaskRunExecutionResult,
|
||||
TaskRunFailedExecutionResult,
|
||||
TaskRunSuccessfulExecutionResult,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { TaskRunError, TaskRunErrorCodes } from "@trigger.dev/core/v3";
|
||||
|
||||
import type {
|
||||
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
|
||||
TaskRun,
|
||||
TaskRunAttempt,
|
||||
TaskRunStatus as TaskRunStatusType,
|
||||
} from "@trigger.dev/database";
|
||||
|
||||
import { assertNever } from "assert-never";
|
||||
import { BatchTaskRunItemStatus, TaskRunAttemptStatus, TaskRunStatus } from "~/database-types";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const SUCCESSFUL_STATUSES = [TaskRunStatus.COMPLETED_SUCCESSFULLY];
|
||||
const FAILURE_STATUSES = [
|
||||
TaskRunStatus.CANCELED,
|
||||
TaskRunStatus.INTERRUPTED,
|
||||
TaskRunStatus.COMPLETED_WITH_ERRORS,
|
||||
TaskRunStatus.SYSTEM_FAILURE,
|
||||
TaskRunStatus.CRASHED,
|
||||
];
|
||||
|
||||
export type TaskRunWithAttempts = TaskRun & {
|
||||
attempts: TaskRunAttempt[];
|
||||
};
|
||||
|
||||
export function executionResultForTaskRun(
|
||||
taskRun: TaskRunWithAttempts
|
||||
): TaskRunExecutionResult | undefined {
|
||||
if (SUCCESSFUL_STATUSES.includes(taskRun.status)) {
|
||||
// find the last attempt that was successful
|
||||
const attempt = taskRun.attempts.find((a) => a.status === TaskRunAttemptStatus.COMPLETED);
|
||||
|
||||
if (!attempt) {
|
||||
logger.error("Task run is successful but no successful attempt found", {
|
||||
taskRunId: taskRun.id,
|
||||
taskRunStatus: taskRun.status,
|
||||
taskRunAttempts: taskRun.attempts.map((a) => a.status),
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
output: attempt.output ?? undefined,
|
||||
outputType: attempt.outputType,
|
||||
} satisfies TaskRunSuccessfulExecutionResult;
|
||||
}
|
||||
|
||||
if (FAILURE_STATUSES.includes(taskRun.status)) {
|
||||
if (taskRun.status === TaskRunStatus.CANCELED) {
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
|
||||
},
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
|
||||
const attempt = taskRun.attempts.find((a) => a.status === TaskRunAttemptStatus.FAILED);
|
||||
|
||||
if (!attempt) {
|
||||
logger.error("Task run is failed but no failed attempt found", {
|
||||
taskRunId: taskRun.id,
|
||||
taskRunStatus: taskRun.status,
|
||||
taskRunAttempts: taskRun.attempts.map((a) => a.status),
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const error = TaskRunError.safeParse(attempt.error);
|
||||
|
||||
if (!error.success) {
|
||||
logger.error("Failed to parse error from failed task run attempt", {
|
||||
taskRunId: taskRun.id,
|
||||
taskRunStatus: taskRun.status,
|
||||
taskRunAttempts: taskRun.attempts.map((a) => a.status),
|
||||
error: attempt.error,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: TaskRunErrorCodes.CONFIGURED_INCORRECTLY,
|
||||
},
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: error.data,
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
}
|
||||
|
||||
export function batchTaskRunItemStatusForRunStatus(
|
||||
status: TaskRunStatusType
|
||||
): BatchTaskRunItemStatusType {
|
||||
switch (status) {
|
||||
case TaskRunStatus.COMPLETED_SUCCESSFULLY:
|
||||
return BatchTaskRunItemStatus.COMPLETED;
|
||||
case TaskRunStatus.CANCELED:
|
||||
case TaskRunStatus.INTERRUPTED:
|
||||
case TaskRunStatus.COMPLETED_WITH_ERRORS:
|
||||
case TaskRunStatus.SYSTEM_FAILURE:
|
||||
case TaskRunStatus.CRASHED:
|
||||
case TaskRunStatus.EXPIRED:
|
||||
case TaskRunStatus.TIMED_OUT:
|
||||
return BatchTaskRunItemStatus.FAILED;
|
||||
case TaskRunStatus.PENDING:
|
||||
case TaskRunStatus.PENDING_VERSION:
|
||||
case TaskRunStatus.WAITING_FOR_DEPLOY:
|
||||
case TaskRunStatus.WAITING_TO_RESUME:
|
||||
case TaskRunStatus.RETRYING_AFTER_FAILURE:
|
||||
case TaskRunStatus.DEQUEUED:
|
||||
case TaskRunStatus.EXECUTING:
|
||||
case TaskRunStatus.PAUSED:
|
||||
case TaskRunStatus.DELAYED:
|
||||
return BatchTaskRunItemStatus.PENDING;
|
||||
default:
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_TAGS_PER_RUN = 10;
|
||||
@@ -0,0 +1,418 @@
|
||||
import type { Prisma, User } from "@trigger.dev/database";
|
||||
import type { GitHubProfile } from "remix-auth-github";
|
||||
import type { GoogleProfile } from "remix-auth-google";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import type { DashboardPreferences } from "~/services/dashboardPreferences.server";
|
||||
import { getDashboardPreferences } from "~/services/dashboardPreferences.server";
|
||||
export type { User } from "@trigger.dev/database";
|
||||
import { assertEmailAllowed } from "~/utils/email";
|
||||
import { emailMatchesPattern } from "~/utils/emailPattern";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type FindOrCreateMagicLink = {
|
||||
authenticationMethod: "MAGIC_LINK";
|
||||
email: string;
|
||||
};
|
||||
|
||||
type FindOrCreateGithub = {
|
||||
authenticationMethod: "GITHUB";
|
||||
email: User["email"];
|
||||
authenticationProfile: GitHubProfile;
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateGoogle = {
|
||||
authenticationMethod: "GOOGLE";
|
||||
email: User["email"];
|
||||
authenticationProfile: GoogleProfile;
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateSso = {
|
||||
authenticationMethod: "SSO";
|
||||
email: User["email"];
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
};
|
||||
|
||||
type FindOrCreateUser =
|
||||
| FindOrCreateMagicLink
|
||||
| FindOrCreateGithub
|
||||
| FindOrCreateGoogle
|
||||
| FindOrCreateSso;
|
||||
|
||||
type LoggedInUser = {
|
||||
user: User;
|
||||
isNewUser: boolean;
|
||||
};
|
||||
|
||||
export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedInUser> {
|
||||
switch (input.authenticationMethod) {
|
||||
case "GITHUB": {
|
||||
return findOrCreateGithubUser(input);
|
||||
}
|
||||
case "MAGIC_LINK": {
|
||||
return findOrCreateMagicLinkUser(input);
|
||||
}
|
||||
case "GOOGLE": {
|
||||
return findOrCreateGoogleUser(input);
|
||||
}
|
||||
case "SSO": {
|
||||
return findOrCreateSsoUser(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function findOrCreateMagicLinkUser({
|
||||
email,
|
||||
}: FindOrCreateMagicLink): Promise<LoggedInUser> {
|
||||
assertEmailAllowed(email);
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const makeAdmin = env.ADMIN_EMAILS ? emailMatchesPattern(env.ADMIN_EMAILS, email) : false;
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
update: {
|
||||
email,
|
||||
},
|
||||
create: {
|
||||
email,
|
||||
authenticationMethod: "MAGIC_LINK",
|
||||
admin: makeAdmin, // only on create, to prevent automatically removing existing admins
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: !existingUser,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOrCreateGithubUser({
|
||||
email,
|
||||
authenticationProfile,
|
||||
authenticationExtraParams,
|
||||
}: FindOrCreateGithub): Promise<LoggedInUser> {
|
||||
assertEmailAllowed(email);
|
||||
|
||||
const name = authenticationProfile._json.name;
|
||||
let avatarUrl: string | undefined = undefined;
|
||||
if (authenticationProfile.photos[0]) {
|
||||
avatarUrl = authenticationProfile.photos[0].value;
|
||||
}
|
||||
const displayName = authenticationProfile.displayName;
|
||||
const authProfile = authenticationProfile
|
||||
? (authenticationProfile as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
const authExtraParams = authenticationExtraParams
|
||||
? (authenticationExtraParams as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
|
||||
const authIdentifier = `github:${authenticationProfile.id}`;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
const existingEmailUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEmailUser && !existingUser) {
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
avatarUrl,
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (existingEmailUser && existingUser) {
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: existingUser.id,
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
name,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
authIdentifier,
|
||||
email,
|
||||
authenticationMethod: "GITHUB",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: !existingUser,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOrCreateGoogleUser({
|
||||
email,
|
||||
authenticationProfile,
|
||||
authenticationExtraParams,
|
||||
}: FindOrCreateGoogle): Promise<LoggedInUser> {
|
||||
assertEmailAllowed(email);
|
||||
|
||||
const name = authenticationProfile._json.name;
|
||||
let avatarUrl: string | undefined = undefined;
|
||||
if (authenticationProfile.photos[0]) {
|
||||
avatarUrl = authenticationProfile.photos[0].value;
|
||||
}
|
||||
const displayName = authenticationProfile.displayName;
|
||||
const authProfile = authenticationProfile
|
||||
? (authenticationProfile as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
const authExtraParams = authenticationExtraParams
|
||||
? (authenticationExtraParams as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
|
||||
const authIdentifier = `google:${authenticationProfile.id}`;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
const existingEmailUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEmailUser && !existingUser) {
|
||||
// Link existing email account to Google auth, preserving original authenticationMethod
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
avatarUrl,
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (existingEmailUser && existingUser) {
|
||||
// Check if email user and auth user are the same
|
||||
if (existingEmailUser.id !== existingUser.id) {
|
||||
// Different users: email is taken by one user, Google auth belongs to another
|
||||
logger.warn(
|
||||
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
|
||||
{
|
||||
email,
|
||||
existingEmailUserId: existingEmailUser.id,
|
||||
existingAuthUserId: existingUser.id,
|
||||
authIdentifier,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
user: existingUser,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Same user: update all profile fields
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: existingUser.id,
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
|
||||
// It's not possible that the email is already taken by a different user because that would have been handled
|
||||
// by one of the if statements above.
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
update: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
create: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
name,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
authIdentifier,
|
||||
email,
|
||||
authenticationMethod: "GOOGLE",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: !existingUser,
|
||||
};
|
||||
}
|
||||
|
||||
// Find an existing user by email (lowercased) or create a new one with the
|
||||
// SSO authentication method. Mirrors the magic-link upsert shape; the
|
||||
// callback route is responsible for normalising email before calling.
|
||||
// Plugin writes (linking the IdP identity row) happen via the SSO plugin
|
||||
// after this returns.
|
||||
export async function findOrCreateSsoUser({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
}: FindOrCreateSso): Promise<LoggedInUser> {
|
||||
// Validate the canonical value we actually look up and persist below —
|
||||
// validating raw `email` would let case/whitespace variants slip past
|
||||
// (or misapply) the allow-list policy.
|
||||
const normalised = email.toLowerCase().trim();
|
||||
assertEmailAllowed(normalised);
|
||||
|
||||
const existingUser = await prisma.user.findFirst({ where: { email: normalised } });
|
||||
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim() || null;
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: normalised },
|
||||
update: {
|
||||
// Existing magic-link / OAuth users keep their original
|
||||
// authenticationMethod; we only refresh name/displayName when the
|
||||
// user has nothing set yet so we don't clobber a customised display
|
||||
// name on every SSO login.
|
||||
...(existingUser?.name ? {} : { name: fullName }),
|
||||
...(existingUser?.displayName ? {} : { displayName: fullName }),
|
||||
},
|
||||
create: {
|
||||
email: normalised,
|
||||
name: fullName,
|
||||
displayName: fullName,
|
||||
authenticationMethod: "SSO",
|
||||
},
|
||||
});
|
||||
|
||||
return { user, isNewUser: !existingUser };
|
||||
}
|
||||
|
||||
export type UserWithDashboardPreferences = User & {
|
||||
dashboardPreferences: DashboardPreferences;
|
||||
};
|
||||
|
||||
export async function getUserById(id: User["id"]) {
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dashboardPreferences = getDashboardPreferences(user.dashboardPreferences);
|
||||
|
||||
return {
|
||||
...user,
|
||||
dashboardPreferences,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: User["email"]) {
|
||||
return prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
export function updateUser({
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
marketingEmails,
|
||||
referralSource,
|
||||
onboardingData,
|
||||
}: Pick<User, "id" | "name" | "email"> & {
|
||||
marketingEmails?: boolean;
|
||||
referralSource?: string;
|
||||
onboardingData?: Prisma.InputJsonValue;
|
||||
}) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
marketingEmails,
|
||||
referralSource,
|
||||
onboardingData,
|
||||
confirmedBasicDetails: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function grantUserCloudAccess({ id, inviteCode }: { id: string; inviteCode: string }) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
invitationCode: {
|
||||
connect: {
|
||||
code: inviteCode,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
import { z } from "zod";
|
||||
import { ResultAsync, okAsync, errAsync } from "neverthrow";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import type { VercelApiError } from "./vercelIntegration.server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery utilities for Vercel SDK validation errors
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The Vercel SDK (Speakeasy-generated) validates API responses with strict Zod
|
||||
// schemas. When the API returns valid data but a field doesn't match the SDK's
|
||||
// type (e.g., `deletedAt: null` vs `number`), a `ResponseValidationError` is
|
||||
// thrown — even though the response contains all the data we need.
|
||||
//
|
||||
// Error hierarchy:
|
||||
// VercelError.body → raw HTTP body text (HTTP errors — never recover)
|
||||
// ResponseValidationError.rawValue → parsed JSON that failed validation
|
||||
// SDKValidationError.rawValue → same pattern, different base class
|
||||
//
|
||||
// Recovery: gate on validation error type → extract rawValue → validate → return.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Only attempt recovery for SDK validation errors — not HTTP errors (401/403).
|
||||
*
|
||||
* ResponseValidationError and SDKValidationError both carry `rawValue` with the
|
||||
* parsed JSON that failed schema validation. VercelError (HTTP errors) carries
|
||||
* `body` instead — we must NOT recover from those since the response is an error
|
||||
* payload, not the data we asked for.
|
||||
*/
|
||||
function isValidationError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
return (
|
||||
error.constructor.name === "ResponseValidationError" ||
|
||||
error.constructor.name === "SDKValidationError" ||
|
||||
"rawValue" in error
|
||||
);
|
||||
}
|
||||
|
||||
function extractRawValue(error: unknown): unknown | undefined {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
if ("rawValue" in error) {
|
||||
return (error as { rawValue: unknown }).rawValue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover usable data from a Vercel SDK error.
|
||||
*
|
||||
* Returns the validated data on success, or `undefined` if recovery fails.
|
||||
*/
|
||||
export function recoverFromVercelSdkError<T>(
|
||||
error: unknown,
|
||||
schema: z.ZodType<any>,
|
||||
options?: { context?: string }
|
||||
): T | undefined {
|
||||
if (!isValidationError(error)) return undefined;
|
||||
|
||||
const raw = extractRawValue(error);
|
||||
if (raw === undefined) return undefined;
|
||||
|
||||
const result = schema.safeParse(raw);
|
||||
if (!result.success) return undefined;
|
||||
|
||||
logger.warn("Recovered data from Vercel SDK validation error", {
|
||||
context: options?.context,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorType: error?.constructor?.name,
|
||||
});
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Vercel SDK promise with automatic recovery on validation errors.
|
||||
*
|
||||
* On success: returns the SDK result as-is.
|
||||
* On error: attempts recovery via rawValue + schema validation (validation errors only).
|
||||
*/
|
||||
export function callVercelWithRecovery<T>(
|
||||
sdkCall: Promise<T>,
|
||||
schema: z.ZodType<any>,
|
||||
options?: { context?: string }
|
||||
): ResultAsync<T, unknown> {
|
||||
return ResultAsync.fromPromise(sdkCall, (error) => error).orElse((error) => {
|
||||
const recovered = recoverFromVercelSdkError<T>(error, schema, options);
|
||||
if (recovered !== undefined) {
|
||||
return okAsync(recovered);
|
||||
}
|
||||
return errAsync(error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `wrapVercelCall` with SDK error recovery.
|
||||
*
|
||||
* Wraps a Vercel SDK promise in ResultAsync with structured error logging,
|
||||
* attempting to recover from validation errors before treating as failure.
|
||||
*/
|
||||
export function wrapVercelCallWithRecovery<T>(
|
||||
promise: Promise<T>,
|
||||
schema: z.ZodType<any>,
|
||||
message: string,
|
||||
context: Record<string, unknown>,
|
||||
toError: (error: unknown) => VercelApiError
|
||||
): ResultAsync<T, VercelApiError> {
|
||||
return callVercelWithRecovery(promise, schema, { context: message }).mapErr((error) => {
|
||||
const apiError = toError(error);
|
||||
logger.error(message, { ...context, error, authInvalid: apiError.authInvalid });
|
||||
return apiError;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal Zod schemas — validate only the fields we actually use.
|
||||
// All use .passthrough() to preserve extra fields from the API response.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VercelSchemas = {
|
||||
getTeam: z.object({ slug: z.string() }).passthrough(),
|
||||
|
||||
getAuthUser: z.object({ user: z.object({ username: z.string() }).passthrough() }).passthrough(),
|
||||
|
||||
getCustomEnvironments: z
|
||||
.object({
|
||||
environments: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string().optional(),
|
||||
branchMatcher: z.unknown().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
|
||||
filterProjectEnvs: z
|
||||
.union([
|
||||
z
|
||||
.object({
|
||||
envs: z.array(z.record(z.unknown())),
|
||||
pagination: z.unknown().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z.array(z.record(z.unknown())),
|
||||
])
|
||||
.transform((val) => (Array.isArray(val) ? { envs: val } : val)),
|
||||
|
||||
getProjectEnv: z.object({ key: z.string(), value: z.string().optional() }).passthrough(),
|
||||
|
||||
getProjects: z.union([
|
||||
z.array(z.object({ id: z.string(), name: z.string() }).passthrough()),
|
||||
z
|
||||
.object({
|
||||
projects: z.array(z.object({ id: z.string(), name: z.string() }).passthrough()),
|
||||
pagination: z.unknown().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
]),
|
||||
|
||||
listSharedEnvVariable: z
|
||||
.object({
|
||||
data: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
target: z.unknown().optional(),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
|
||||
getSharedEnvVar: z.object({ value: z.string().optional() }).passthrough(),
|
||||
|
||||
updateProject: z
|
||||
.object({ id: z.string(), name: z.string(), autoAssignCustomDomains: z.boolean().optional() })
|
||||
.passthrough(),
|
||||
} as const;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Prisma } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export const MAX_TAGS_PER_WAITPOINT = 10;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export async function createWaitpointTag({
|
||||
tag,
|
||||
environmentId,
|
||||
projectId,
|
||||
}: {
|
||||
tag: string;
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
if (tag.trim().length === 0) return;
|
||||
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < MAX_RETRIES) {
|
||||
try {
|
||||
return await prisma.waitpointTag.upsert({
|
||||
where: {
|
||||
environmentId_name: {
|
||||
environmentId,
|
||||
name: tag,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag,
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
// Handle unique constraint violation (conflict)
|
||||
attempts++;
|
||||
if (attempts >= MAX_RETRIES) {
|
||||
throw new Error(
|
||||
`Failed to create waitpoint tag after ${MAX_RETRIES} attempts due to conflicts.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw error; // Re-throw other errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user