chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
+284
View File
@@ -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),
},
});
}
+126
View File
@@ -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);
}
+574
View File
@@ -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 };
}
+259
View File
@@ -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`);
}
}
}
}
+289
View File
@@ -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 });
}
+163
View File
@@ -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,
};
}
+25
View File
@@ -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);
}
+140
View File
@@ -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;
+418
View File
@@ -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
}
}
}
}