diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 77ae2e8317..683a512c40 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -59,6 +59,7 @@ const EnvironmentSchema = z ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(), REMIX_APP_PORT: z.string().optional(), LOGIN_ORIGIN: z.string().default("http://localhost:3030"), + LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true), APP_ORIGIN: z.string().default("http://localhost:3030"), API_ORIGIN: z.string().optional(), STREAM_ORIGIN: z.string().optional(), diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 86ae5d371d..04c1df1b41 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,5 +1,9 @@ -import { prisma } from "~/db.server"; +import { type Prisma, prisma } from "~/db.server"; import { createEnvironment } from "./organization.server"; +import { customAlphabet } from "nanoid"; + +const tokenValueLength = 40; +const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength); export async function getTeamMembersAndInvites({ userId, @@ -95,14 +99,19 @@ export async function inviteMembers({ throw new Error("User does not have access to this organization"); } - const created = await prisma.orgMemberInvite.createMany({ - data: emails.map((email) => ({ - email, - organizationId: org.id, - inviterId: userId, - role: "MEMBER", - })), - skipDuplicates: true, + const invites = [...new Set(emails)].map( + (email) => + ({ + email, + token: tokenGenerator(), + organizationId: org.id, + inviterId: userId, + role: "MEMBER", + } satisfies Prisma.OrgMemberInviteCreateManyInput) + ); + + await prisma.orgMemberInvite.createMany({ + data: invites, }); return await prisma.orgMemberInvite.findMany({ @@ -147,12 +156,19 @@ export async function getUsersInvites({ email }: { email: string }) { }); } -export async function acceptInvite({ userId, inviteId }: { userId: string; inviteId: string }) { +export async function acceptInvite({ + user, + inviteId, +}: { + user: { id: string; email: string }; + inviteId: string; +}) { return await prisma.$transaction(async (tx) => { // 1. Delete the invite and get the invite details const invite = await tx.orgMemberInvite.delete({ where: { id: inviteId, + email: user.email, }, include: { organization: { @@ -167,7 +183,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit const member = await tx.orgMember.create({ data: { organizationId: invite.organizationId, - userId, + userId: user.id, role: invite.role, }, }); @@ -187,7 +203,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit // 4. Check for other invites const remainingInvites = await tx.orgMemberInvite.findMany({ where: { - email: invite.email, + email: user.email, }, }); @@ -195,28 +211,29 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit }); } -export async function declineInvite({ userId, inviteId }: { userId: string; inviteId: string }) { +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 prisma.orgMemberInvite.delete({ where: { id: inviteId, + email: user.email, }, include: { organization: true, }, }); - //2. get email - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { email: true }, - }); - - //3. check for other invites + //2. check for other invites const remainingInvites = await prisma.orgMemberInvite.findMany({ where: { - email: user!.email, + email: user.email, }, }); @@ -224,10 +241,11 @@ export async function declineInvite({ userId, inviteId }: { userId: string; invi }); } -export async function resendInvite({ inviteId }: { inviteId: string }) { +export async function resendInvite({ inviteId, userId }: { inviteId: string; userId: string }) { return await prisma.orgMemberInvite.update({ where: { id: inviteId, + inviterId: userId, }, data: { updatedAt: new Date(), @@ -241,26 +259,27 @@ export async function resendInvite({ inviteId }: { inviteId: string }) { export async function revokeInvite({ userId, - slug, + orgSlug, inviteId, }: { userId: string; - slug: string; + orgSlug: string; inviteId: string; }) { - 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 invite = await prisma.orgMemberInvite.delete({ + const invite = await prisma.orgMemberInvite.findFirst({ where: { id: inviteId, - organizationId: org.id, + organization: { + slug: orgSlug, + members: { + some: { + userId, + }, + }, + }, }, select: { + id: true, email: true, organization: true, }, @@ -270,5 +289,11 @@ export async function revokeInvite({ throw new Error("Invite not found"); } + await prisma.orgMemberInvite.delete({ + where: { + id: invite.id, + }, + }); + return { email: invite.email, organization: invite.organization }; } diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 400e872a21..4ec0260e71 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -28,7 +28,6 @@ export class RunPresenter { public async call({ userId, projectSlug, - organizationSlug, environmentSlug, runFriendlyId, showDeletedLogs, @@ -36,7 +35,6 @@ export class RunPresenter { }: { userId: string; projectSlug: string; - organizationSlug: string; environmentSlug: string; runFriendlyId: string; showDeletedLogs: boolean; @@ -93,6 +91,13 @@ export class RunPresenter { friendlyId: runFriendlyId, project: { slug: projectSlug, + organization: { + members: { + some: { + userId, + }, + }, + }, }, }, }); diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 7919e075b2..94d10cb1c2 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -28,10 +28,12 @@ type GetSpanResult = NonNullable { return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; }; +export const headers = () => ({ + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", + "Permissions-Policy": + "geolocation=(), microphone=(), camera=(), accelerometer=(), gyroscope=(), magnetometer=(), payment=(), usb=()", +}); + export const meta: MetaFunction = ({ data }) => { const typedData = data as UseDataFunctionReturn; return [ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 890ed00433..5948f87dc8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -142,7 +142,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const [error, result] = await tryCatch( presenter.call({ userId, - organizationSlug, showDeletedLogs: !!impersonationId, projectSlug: projectParam, runFriendlyId: runParam, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx index 4dd0352f89..eb0a8138b0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing-alerts/route.tsx @@ -47,7 +47,7 @@ export const meta: MetaFunction = () => { }; export async function loader({ params, request }: LoaderFunctionArgs) { - await requireUserId(request); + const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); const { isManagedCloud } = featuresForRequest(request); @@ -55,8 +55,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return redirect(organizationPath({ slug: organizationSlug })); } - const organization = await prisma.organization.findUnique({ - where: { slug: organizationSlug }, + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, }); if (!organization) { @@ -181,7 +181,7 @@ export default function Page() { const fieldValues = useRef(alerts.emails); const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails }); - const checkboxLevels = [0.75, 0.9, 1.0]; + const checkboxLevels = [0.75, 0.9, 1.0, 2.0, 5.0]; useEffect(() => { if (alerts.emails.length > 0) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx index 0dbffffc4d..e78fe138fb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx @@ -28,7 +28,7 @@ export const meta: MetaFunction = () => { }; export async function loader({ params, request }: LoaderFunctionArgs) { - await requireUserId(request); + const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); const { isManagedCloud } = featuresForRequest(request); @@ -41,8 +41,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { throw new Response(null, { status: 404, statusText: "Plans not found" }); } - const organization = await prisma.organization.findUnique({ - where: { slug: organizationSlug }, + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, }); if (!organization) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx index 9e92c27f2b..cfefb3938c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx @@ -46,7 +46,7 @@ export const meta: MetaFunction = () => { }; export async function loader({ params, request }: LoaderFunctionArgs) { - await requireUserId(request); + const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); const { isManagedCloud } = featuresForRequest(request); @@ -54,8 +54,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return redirect(organizationPath({ slug: organizationSlug })); } - const organization = await prisma.organization.findUnique({ - where: { slug: organizationSlug }, + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, }); if (!organization) { diff --git a/apps/webapp/app/routes/account.tokens/route.tsx b/apps/webapp/app/routes/account.tokens/route.tsx index 7b7945c70f..4ad62d7edc 100644 --- a/apps/webapp/app/routes/account.tokens/route.tsx +++ b/apps/webapp/app/routes/account.tokens/route.tsx @@ -3,8 +3,8 @@ import { parse } from "@conform-to/zod"; import { BookOpenIcon, ShieldCheckIcon, TrashIcon } from "@heroicons/react/20/solid"; import { ShieldExclamationIcon } from "@heroicons/react/24/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, MetaFunction, useActionData, useFetcher } from "@remix-run/react"; -import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { Form, type MetaFunction, useActionData, useFetcher } from "@remix-run/react"; +import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; @@ -16,7 +16,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; -import { Header2 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; @@ -36,8 +35,8 @@ import { import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { - CreatedPersonalAccessToken, - ObfuscatedPersonalAccessToken, + type CreatedPersonalAccessToken, + type ObfuscatedPersonalAccessToken, createPersonalAccessToken, getValidPersonalAccessTokens, revokePersonalAccessToken, @@ -53,7 +52,7 @@ export const meta: MetaFunction = () => { ]; }; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { +export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await requireUserId(request); try { @@ -113,7 +112,7 @@ export const action: ActionFunction = async ({ request }) => { } case "revoke": { try { - await revokePersonalAccessToken(submission.value.tokenId); + await revokePersonalAccessToken(submission.value.tokenId, userId); return redirectWithSuccessMessage( personalAccessTokensPath(), @@ -125,6 +124,7 @@ export const action: ActionFunction = async ({ request }) => { } } default: { + submission.value satisfies never; return json({ errors: { body: "Invalid action" } }, { status: 400 }); } } diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.repair-queues.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.repair-queues.ts new file mode 100644 index 0000000000..30d60197f9 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.repair-queues.ts @@ -0,0 +1,105 @@ +import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import pMap from "p-map"; +import { z } from "zod"; +import { $replica, prisma } from "~/db.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { determineEngineVersion } from "~/v3/engineVersion.server"; +import { engine } from "~/v3/runEngine.server"; + +const ParamsSchema = z.object({ + environmentId: z.string(), +}); + +const BodySchema = z.object({ + dryRun: z.boolean().default(true), + queues: z.array(z.string()).default([]), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + // Next authenticate the request + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { + id: authenticationResult.userId, + }, + }); + + if (!user) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + if (!user.admin) { + return json({ error: "You must be an admin to perform this action" }, { status: 403 }); + } + + const parsedParams = ParamsSchema.parse(params); + + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + id: parsedParams.environmentId, + }, + include: { + organization: true, + project: true, + orgMember: true, + }, + }); + + if (!environment) { + return json({ error: "Environment not found" }, { status: 404 }); + } + + const engineVersion = await determineEngineVersion({ environment }); + + if (engineVersion === "V1") { + return json({ error: "Engine version is V1" }, { status: 400 }); + } + + const body = await request.json(); + const parsedBody = BodySchema.parse(body); + + const queues = await $replica.taskQueue.findMany({ + where: { + runtimeEnvironmentId: environment.id, + version: "V2", + name: parsedBody.queues.length > 0 ? { in: parsedBody.queues } : undefined, + }, + select: { + friendlyId: true, + name: true, + concurrencyLimit: true, + type: true, + paused: true, + }, + orderBy: { + orderableName: "asc", + }, + }); + + const repairEnvironmentResults = await engine.repairEnvironment(environment, parsedBody.dryRun); + + const repairResults = await pMap( + queues, + async (queue) => { + const repair = await engine.repairQueue( + environment, + queue.name, + parsedBody.dryRun, + repairEnvironmentResults.runIds + ); + + return { + queue: queue.name, + ...repair, + }; + }, + { concurrency: 5 } + ); + + return json({ environment: repairEnvironmentResults, queues: repairResults }); +} diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.report.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.report.ts new file mode 100644 index 0000000000..3ea9576899 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.engine.report.ts @@ -0,0 +1,95 @@ +import { json, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { $replica, prisma } from "~/db.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { determineEngineVersion } from "~/v3/engineVersion.server"; +import { engine } from "~/v3/runEngine.server"; + +const ParamsSchema = z.object({ + environmentId: z.string(), +}); + +const SearchParamsSchema = z.object({ + verbose: z.string().default("0"), + page: z.coerce.number().optional(), + per_page: z.coerce.number().optional(), +}); + +export async function loader({ request, params }: LoaderFunctionArgs) { + // Next authenticate the request + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { + id: authenticationResult.userId, + }, + }); + + if (!user) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + if (!user.admin) { + return json({ error: "You must be an admin to perform this action" }, { status: 403 }); + } + + const parsedParams = ParamsSchema.parse(params); + + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + id: parsedParams.environmentId, + }, + include: { + organization: true, + project: true, + orgMember: true, + }, + }); + + if (!environment) { + return json({ error: "Environment not found" }, { status: 404 }); + } + + const engineVersion = await determineEngineVersion({ environment }); + + if (engineVersion === "V1") { + return json({ error: "Engine version is V1" }, { status: 400 }); + } + + const url = new URL(request.url); + const searchParams = SearchParamsSchema.parse(Object.fromEntries(url.searchParams)); + + const page = searchParams.page ?? 1; + const perPage = searchParams.per_page ?? 50; + + const queues = await $replica.taskQueue.findMany({ + where: { + runtimeEnvironmentId: environment.id, + version: "V2", + }, + select: { + friendlyId: true, + name: true, + concurrencyLimit: true, + type: true, + paused: true, + }, + orderBy: { + orderableName: "asc", + }, + skip: (page - 1) * perPage, + take: perPage, + }); + + const report = await engine.generateEnvironmentReport( + environment, + queues, + searchParams.verbose === "1" + ); + + return json(report); +} diff --git a/apps/webapp/app/routes/invite-accept.tsx b/apps/webapp/app/routes/invite-accept.tsx index 57c1ddff06..592384b951 100644 --- a/apps/webapp/app/routes/invite-accept.tsx +++ b/apps/webapp/app/routes/invite-accept.tsx @@ -18,6 +18,12 @@ export async function loader({ request }: LoaderFunctionArgs) { ); } + if (!user) { + return redirectWithSuccessMessage("/", request, "Please log in to accept the invite.", { + ephemeral: false, + }); + } + const invite = await getInviteFromToken({ token }); if (!invite) { return redirectWithErrorMessage( @@ -28,12 +34,6 @@ export async function loader({ request }: LoaderFunctionArgs) { ); } - if (!user) { - return redirectWithSuccessMessage("/", request, "Please log in to accept the invite.", { - ephemeral: false, - }); - } - if (invite.email !== user.email) { return redirectWithErrorMessage( "/", diff --git a/apps/webapp/app/routes/invite-resend.tsx b/apps/webapp/app/routes/invite-resend.tsx index 9d5ee08abe..dc66e89851 100644 --- a/apps/webapp/app/routes/invite-resend.tsx +++ b/apps/webapp/app/routes/invite-resend.tsx @@ -1,5 +1,5 @@ import { parse } from "@conform-to/zod"; -import { ActionFunction, json } from "@remix-run/server-runtime"; +import { type ActionFunction, json } from "@remix-run/server-runtime"; import { env } from "process"; import { z } from "zod"; import { resendInvite } from "~/models/member.server"; @@ -25,6 +25,7 @@ export const action: ActionFunction = async ({ request }) => { try { const invite = await resendInvite({ inviteId: submission.value.inviteId, + userId, }); try { diff --git a/apps/webapp/app/routes/invite-revoke.tsx b/apps/webapp/app/routes/invite-revoke.tsx index b066a08ba3..cd499e58dc 100644 --- a/apps/webapp/app/routes/invite-revoke.tsx +++ b/apps/webapp/app/routes/invite-revoke.tsx @@ -1,5 +1,5 @@ import { parse } from "@conform-to/zod"; -import { ActionFunction, json } from "@remix-run/server-runtime"; +import { type ActionFunction, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { revokeInvite } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; @@ -24,7 +24,7 @@ export const action: ActionFunction = async ({ request }) => { try { const { email, organization } = await revokeInvite({ userId, - slug: submission.value.slug, + orgSlug: submission.value.slug, inviteId: submission.value.inviteId, }); diff --git a/apps/webapp/app/routes/invites.tsx b/apps/webapp/app/routes/invites.tsx index c4bd0057ec..11998a4676 100644 --- a/apps/webapp/app/routes/invites.tsx +++ b/apps/webapp/app/routes/invites.tsx @@ -1,6 +1,6 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { ActionFunction, LoaderFunctionArgs, json, redirect } from "@remix-run/node"; +import { type ActionFunction, type LoaderFunctionArgs, json, redirect } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -36,7 +36,7 @@ const schema = z.object({ }); export const action: ActionFunction = async ({ request }) => { - const userId = await requireUserId(request); + const user = await requireUser(request); const formData = await request.formData(); const submission = parse(formData, { schema }); @@ -49,7 +49,7 @@ export const action: ActionFunction = async ({ request }) => { if (submission.intent === "accept") { const { remainingInvites, organization } = await acceptInvite({ inviteId: submission.value.inviteId, - userId, + user: { id: user.id, email: user.email }, }); if (remainingInvites.length === 0) { @@ -64,7 +64,7 @@ export const action: ActionFunction = async ({ request }) => { } else if (submission.intent === "decline") { const { remainingInvites, organization } = await declineInvite({ inviteId: submission.value.inviteId, - userId, + user: { id: user.id, email: user.email }, }); if (remainingInvites.length === 0) { return redirectWithSuccessMessage( diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 04085ebd61..8c2015c5e6 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -1,7 +1,11 @@ import { ArrowLeftIcon, EnvelopeIcon } from "@heroicons/react/20/solid"; import { InboxArrowDownIcon } from "@heroicons/react/24/solid"; -import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import { + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from "@remix-run/node"; import { Form, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -18,6 +22,14 @@ import { Spinner } from "~/components/primitives/Spinner"; import { TextLink } from "~/components/primitives/TextLink"; import { authenticator } from "~/services/auth.server"; import { commitSession, getUserSession } from "~/services/sessionStorage.server"; +import { + checkMagicLinkEmailRateLimit, + checkMagicLinkEmailDailyRateLimit, + MagicLinkRateLimitError, + checkMagicLinkIpRateLimit, +} from "~/services/magicLinkRateLimiter.server"; +import { logger, tryCatch } from "@trigger.dev/core/v3"; +import { env } from "~/env.server"; export const meta: MetaFunction = ({ matches }) => { const parentMeta = matches @@ -71,29 +83,99 @@ export async function action({ request }: ActionFunctionArgs) { const payload = Object.fromEntries(await clonedRequest.formData()); - const { action } = z - .object({ - action: z.enum(["send", "reset"]), - }) + const data = z + .discriminatedUnion("action", [ + z.object({ + action: z.literal("send"), + email: z.string().trim().toLowerCase(), + }), + z.object({ + action: z.literal("reset"), + }), + ]) .parse(payload); - if (action === "send") { - return authenticator.authenticate("email-link", request, { - successRedirect: "/login/magic", - failureRedirect: "/login/magic", - }); - } else { - const session = await getUserSession(request); - session.unset("triggerdotdev:magiclink"); - - return redirect("/login/magic", { - headers: { - "Set-Cookie": await commitSession(session), - }, - }); + switch (data.action) { + case "send": { + if (!env.LOGIN_RATE_LIMITS_ENABLED) { + return authenticator.authenticate("email-link", request, { + successRedirect: "/login/magic", + failureRedirect: "/login/magic", + }); + } + + const { email } = data; + const xff = request.headers.get("x-forwarded-for"); + const clientIp = extractClientIp(xff); + + const [error] = await tryCatch( + Promise.all([ + clientIp ? checkMagicLinkIpRateLimit(clientIp) : Promise.resolve(), + checkMagicLinkEmailRateLimit(email), + checkMagicLinkEmailDailyRateLimit(email), + ]) + ); + + if (error) { + if (error instanceof MagicLinkRateLimitError) { + logger.warn("Login magic link rate limit exceeded", { + clientIp, + email, + error, + }); + } else { + logger.error("Failed sending login magic link", { + clientIp, + email, + error, + }); + } + + const errorMessage = + error instanceof MagicLinkRateLimitError + ? "Too many magic link requests. Please try again shortly." + : "Failed sending magic link. Please try again shortly."; + + const session = await getUserSession(request); + session.set("auth:error", { + message: errorMessage, + }); + + return redirect("/login/magic", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } + + return authenticator.authenticate("email-link", request, { + successRedirect: "/login/magic", + failureRedirect: "/login/magic", + }); + } + case "reset": + default: { + data.action satisfies "reset"; + + const session = await getUserSession(request); + session.unset("triggerdotdev:magiclink"); + + return redirect("/login/magic", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } } } +const extractClientIp = (xff: string | null) => { + if (!xff) return null; + + const parts = xff.split(",").map((p) => p.trim()); + return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default +}; + export default function LoginMagicLinkPage() { const { magicLinkSent, magicLinkError } = useTypedLoaderData(); const navigate = useNavigation(); diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.runs.$runParam.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.runs.$runParam.ts index 29aa557797..63a89d7e0a 100644 --- a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.runs.$runParam.ts +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.runs.$runParam.ts @@ -3,14 +3,14 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUserId } from "~/services/session.server"; -import { ProjectParamSchema, v3DeploymentPath, v3RunPath } from "~/utils/pathBuilder"; +import { ProjectParamSchema, v3RunPath } from "~/utils/pathBuilder"; const ParamSchema = ProjectParamSchema.extend({ runParam: z.string(), }); export const loader = async ({ request, params }: LoaderFunctionArgs) => { - await requireUserId(request); + const userId = await requireUserId(request); const { organizationSlug, projectParam, runParam } = ParamSchema.parse(params); const run = await prisma.taskRun.findFirst({ @@ -18,6 +18,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { friendlyId: runParam, project: { slug: projectParam, + organization: { + slug: organizationSlug, + members: { + some: { + userId, + }, + }, + }, }, }, select: { diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 66d166294b..29a5cb873a 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -78,8 +78,10 @@ import { } from "~/utils/pathBuilder"; import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents"; import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; +import { requireUserId } from "~/services/session.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); const { projectParam, organizationSlug, envParam, runParam, spanParam } = v3SpanParamsSchema.parse(params); @@ -90,6 +92,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { projectSlug: projectParam, spanId: spanParam, runFriendlyId: runParam, + userId, }); return typedjson(result); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 8f511f0b08..8d9e19bfc6 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -7,16 +7,16 @@ import { } from "@heroicons/react/20/solid"; import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline"; import { Form, useLocation, useNavigation } from "@remix-run/react"; -import { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { uiComponent } from "@team-plain/typescript-sdk"; import { GitHubLightIcon } from "@trigger.dev/companyicons"; import { - FreePlanDefinition, - Limits, - PaidPlanDefinition, - Plans, - SetPlanBody, - SubscriptionResult, + type FreePlanDefinition, + type Limits, + type PaidPlanDefinition, + type Plans, + type SetPlanBody, + type SubscriptionResult, } from "@trigger.dev/platform"; import React, { useEffect, useState } from "react"; import { z } from "zod"; @@ -75,8 +75,8 @@ export async function action({ request, params }: ActionFunctionArgs) { message: message || undefined, }); - const organization = await prisma.organization.findUnique({ - where: { slug: organizationSlug }, + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId: user.id } } }, }); if (!organization) { diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts index 676d4dabe2..240d7d3d8e 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts @@ -1,9 +1,10 @@ import { parse } from "@conform-to/zod"; -import { ActionFunction, json } from "@remix-run/node"; +import { type ActionFunction, json } from "@remix-run/node"; import { z } from "zod"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server"; export const cancelSchema = z.object({ @@ -15,6 +16,7 @@ const ParamSchema = z.object({ }); export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); const { runParam } = ParamSchema.parse(params); const formData = await request.formData(); @@ -25,9 +27,18 @@ export const action: ActionFunction = async ({ request, params }) => { } try { - const taskRun = await prisma.taskRun.findUnique({ + const taskRun = await prisma.taskRun.findFirst({ where: { friendlyId: runParam, + project: { + organization: { + members: { + some: { + userId, + }, + }, + }, + }, }, }); diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.debug.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.debug.ts index ba48f7085a..d7acf18e51 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.debug.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.debug.ts @@ -1,4 +1,4 @@ -import { LoaderFunctionArgs } from "@remix-run/node"; +import { type LoaderFunctionArgs } from "@remix-run/node"; import { typedjson } from "remix-typedjson"; import { z } from "zod"; import { $replica } from "~/db.server"; diff --git a/apps/webapp/app/services/magicLinkRateLimiter.server.ts b/apps/webapp/app/services/magicLinkRateLimiter.server.ts new file mode 100644 index 0000000000..2944e46796 --- /dev/null +++ b/apps/webapp/app/services/magicLinkRateLimiter.server.ts @@ -0,0 +1,96 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { env } from "~/env.server"; +import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server"; +import { singleton } from "~/utils/singleton"; + +export class MagicLinkRateLimitError extends Error { + public readonly retryAfter: number; + + constructor(retryAfter: number) { + super("Magic link request rate limit exceeded."); + this.retryAfter = retryAfter; + } +} + +function getRedisClient() { + return createRedisRateLimitClient({ + port: env.RATE_LIMIT_REDIS_PORT, + host: env.RATE_LIMIT_REDIS_HOST, + username: env.RATE_LIMIT_REDIS_USERNAME, + password: env.RATE_LIMIT_REDIS_PASSWORD, + tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", + clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", + }); +} + +const magicLinkEmailRateLimiter = singleton( + "magicLinkEmailRateLimiter", + initializeMagicLinkEmailRateLimiter +); + +function initializeMagicLinkEmailRateLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:magiclink:email", + limiter: Ratelimit.slidingWindow(3, "1 m"), // 3 requests per minute per email + logSuccess: false, + logFailure: true, + }); +} + +const magicLinkEmailDailyRateLimiter = singleton( + "magicLinkEmailDailyRateLimiter", + initializeMagicLinkEmailDailyRateLimiter +); + +function initializeMagicLinkEmailDailyRateLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:magiclink:email:daily", + limiter: Ratelimit.slidingWindow(30, "1 d"), // 30 requests per day per email + logSuccess: false, + logFailure: true, + }); +} + +const magicLinkIpRateLimiter = singleton( + "magicLinkIpRateLimiter", + initializeMagicLinkIpRateLimiter +); + +function initializeMagicLinkIpRateLimiter() { + return new RateLimiter({ + redisClient: getRedisClient(), + keyPrefix: "auth:magiclink:ip", + limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute per IP + logSuccess: false, + logFailure: true, + }); +} + +export async function checkMagicLinkEmailRateLimit(identifier: string): Promise { + const result = await magicLinkEmailRateLimiter.limit(identifier); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MagicLinkRateLimitError(retryAfter); + } +} + +export async function checkMagicLinkEmailDailyRateLimit(identifier: string): Promise { + const result = await magicLinkEmailDailyRateLimiter.limit(identifier); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MagicLinkRateLimitError(retryAfter); + } +} + +export async function checkMagicLinkIpRateLimit(ip: string): Promise { + const result = await magicLinkIpRateLimiter.limit(ip); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MagicLinkRateLimitError(retryAfter); + } +} diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index 80a251f657..ebe8bc31ff 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -79,15 +79,20 @@ export async function getPersonalAccessTokenFromAuthorizationCode(authorizationC }; } -export async function revokePersonalAccessToken(tokenId: string) { - await prisma.personalAccessToken.update({ +export async function revokePersonalAccessToken(tokenId: string, userId: string) { + const result = await prisma.personalAccessToken.updateMany({ where: { id: tokenId, + userId, }, data: { revokedAt: new Date(), }, }); + + if (result.count === 0) { + throw new Error("PAT not found or already revoked"); + } } export type PersonalAccessTokenAuthenticationResult = { diff --git a/apps/webapp/app/v3/eventRepository.server.ts b/apps/webapp/app/v3/eventRepository.server.ts index 263125ba75..d19a2963ee 100644 --- a/apps/webapp/app/v3/eventRepository.server.ts +++ b/apps/webapp/app/v3/eventRepository.server.ts @@ -970,22 +970,30 @@ export class EventRepository { // A Span can be cancelled if it is partial and has a parent that is cancelled // And a span's duration, if it is partial and has a cancelled parent, is the time between the start of the span and the time of the cancellation event of the parent - public async getSpan( - storeTable: TaskEventStoreTable, - spanId: string, - traceId: string, - startCreatedAt: Date, - endCreatedAt?: Date, - options?: { includeDebugLogs?: boolean } - ) { - return await startActiveSpan("getSpan", async (s) => { - const spanEvent = await this.#getSpanEvent( + public async getSpan({ + storeTable, + spanId, + environmentId, + startCreatedAt, + endCreatedAt, + options, + }: { + storeTable: TaskEventStoreTable; + spanId: string; + environmentId: string; + startCreatedAt: Date; + endCreatedAt?: Date; + options?: { includeDebugLogs?: boolean }; + }) { + return await startActiveSpan("getSpan", async () => { + const spanEvent = await this.#getSpanEvent({ storeTable, spanId, + environmentId, startCreatedAt, endCreatedAt, - options - ); + options, + }); if (!spanEvent) { return; @@ -996,6 +1004,7 @@ export class EventRepository { const span = await this.#createSpanFromEvent( storeTable, preparedEvent, + environmentId, startCreatedAt, endCreatedAt ); @@ -1081,6 +1090,7 @@ export class EventRepository { async #createSpanFromEvent( storeTable: TaskEventStoreTable, event: PreparedEvent, + environmentId: string, startCreatedAt: Date, endCreatedAt?: Date ) { @@ -1091,6 +1101,7 @@ export class EventRepository { await this.#walkSpanAncestors( storeTable, event, + environmentId, startCreatedAt, endCreatedAt, (ancestorEvent, level) => { @@ -1185,6 +1196,7 @@ export class EventRepository { async #walkSpanAncestors( storeTable: TaskEventStoreTable, event: PreparedEvent, + environmentId: string, startCreatedAt: Date, endCreatedAt: Date | undefined, callback: (event: PreparedEvent, level: number) => { stop: boolean } @@ -1195,12 +1207,13 @@ export class EventRepository { } await startActiveSpan("walkSpanAncestors", async (s) => { - let parentEvent = await this.#getSpanEvent( + let parentEvent = await this.#getSpanEvent({ storeTable, - parentId, + spanId: parentId, + environmentId, startCreatedAt, - endCreatedAt - ); + endCreatedAt, + }); let level = 1; while (parentEvent) { @@ -1216,29 +1229,38 @@ export class EventRepository { return; } - parentEvent = await this.#getSpanEvent( + parentEvent = await this.#getSpanEvent({ storeTable, - preparedParentEvent.parentId, + spanId: preparedParentEvent.parentId, + environmentId, startCreatedAt, - endCreatedAt - ); + endCreatedAt, + }); level++; } }); } - async #getSpanEvent( - storeTable: TaskEventStoreTable, - spanId: string, - startCreatedAt: Date, - endCreatedAt?: Date, - options?: { includeDebugLogs?: boolean } - ) { + async #getSpanEvent({ + storeTable, + spanId, + environmentId, + startCreatedAt, + endCreatedAt, + options, + }: { + storeTable: TaskEventStoreTable; + spanId: string; + environmentId: string; + startCreatedAt: Date; + endCreatedAt?: Date; + options?: { includeDebugLogs?: boolean }; + }) { return await startActiveSpan("getSpanEvent", async (s) => { const events = await this.taskEventStore.findMany( storeTable, - { spanId }, + { spanId, environmentId }, startCreatedAt, endCreatedAt, undefined, diff --git a/apps/webapp/public/emails/logo-triangle.png b/apps/webapp/public/emails/logo-triangle.png new file mode 100644 index 0000000000..59ae39f5d8 Binary files /dev/null and b/apps/webapp/public/emails/logo-triangle.png differ diff --git a/docker/dev-compose.yml b/docker/dev-compose.yml index ff3a7d9b24..dd39ae7d8a 100644 --- a/docker/dev-compose.yml +++ b/docker/dev-compose.yml @@ -48,7 +48,7 @@ services: - db clickhouse: - image: bitnami/clickhouse:latest + image: bitnamilegacy/clickhouse:latest container_name: clickhouse-dev environment: CLICKHOUSE_ADMIN_USER: default diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9a0b97569c..7d5a81e30f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -76,7 +76,7 @@ services: - database clickhouse: - image: bitnami/clickhouse:latest + image: bitnamilegacy/clickhouse:latest restart: always container_name: clickhouse environment: diff --git a/docs/idempotency.mdx b/docs/idempotency.mdx index 56898c3cfd..65d4c7bd04 100644 --- a/docs/idempotency.mdx +++ b/docs/idempotency.mdx @@ -5,11 +5,6 @@ description: "An API call or operation is “idempotent” if it has the same re We currently support idempotency at the task level, meaning that if you trigger a task with the same `idempotencyKey` twice, the second request will not create a new task run. - - In version 3.3.0 and later, the `idempotencyKey` option is not available when using - `triggerAndWait` or `batchTriggerAndWait`, due to a bug that would sometimes cause the parent task - to become stuck. We are working on a fix for this issue. - ## `idempotencyKey` option diff --git a/hosting/docker/webapp/docker-compose.yml b/hosting/docker/webapp/docker-compose.yml index 03aec05a6c..74331bbc9d 100644 --- a/hosting/docker/webapp/docker-compose.yml +++ b/hosting/docker/webapp/docker-compose.yml @@ -139,7 +139,7 @@ services: start_period: 10s clickhouse: - image: bitnami/clickhouse:${CLICKHOUSE_IMAGE_TAG:-latest} + image: bitnamilegacy/clickhouse:${CLICKHOUSE_IMAGE_TAG:-latest} restart: ${RESTART_POLICY:-unless-stopped} logging: *logging-config ports: @@ -183,7 +183,7 @@ services: start_period: 10s minio: - image: bitnami/minio:${MINIO_IMAGE_TAG:-latest} + image: bitnamilegacy/minio:${MINIO_IMAGE_TAG:-latest} restart: ${RESTART_POLICY:-unless-stopped} logging: *logging-config ports: diff --git a/hosting/k8s/helm/Chart.yaml b/hosting/k8s/helm/Chart.yaml index 42d962130f..e74648bbff 100644 --- a/hosting/k8s/helm/Chart.yaml +++ b/hosting/k8s/helm/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: trigger description: The official Trigger.dev Helm chart type: application -version: 4.0.1 +version: 4.0.3 appVersion: v4.0.4 home: https://trigger.dev sources: diff --git a/hosting/k8s/helm/templates/_helpers.tpl b/hosting/k8s/helm/templates/_helpers.tpl index 6d2f5fdb57..cb148678c9 100644 --- a/hosting/k8s/helm/templates/_helpers.tpl +++ b/hosting/k8s/helm/templates/_helpers.tpl @@ -95,6 +95,34 @@ Get the full image name for supervisor {{- end }} {{- end }} +{{/* +Get the full image name for webapp volumePermissions init container +*/}} +{{- define "trigger-v4.webapp.volumePermissions.image" -}} +{{- $registry := .Values.global.imageRegistry | default .Values.webapp.volumePermissions.image.registry -}} +{{- $repository := .Values.webapp.volumePermissions.image.repository -}} +{{- $tag := .Values.webapp.volumePermissions.image.tag -}} +{{- if $registry }} +{{- printf "%s/%s:%s" $registry $repository $tag }} +{{- else }} +{{- printf "%s:%s" $repository $tag }} +{{- end }} +{{- end }} + +{{/* +Get the full image name for webapp tokenSyncer sidecar +*/}} +{{- define "trigger-v4.webapp.tokenSyncer.image" -}} +{{- $registry := .Values.global.imageRegistry | default .Values.webapp.tokenSyncer.image.registry -}} +{{- $repository := .Values.webapp.tokenSyncer.image.repository -}} +{{- $tag := .Values.webapp.tokenSyncer.image.tag -}} +{{- if $registry }} +{{- printf "%s/%s:%s" $registry $repository $tag }} +{{- else }} +{{- printf "%s:%s" $repository $tag }} +{{- end }} +{{- end }} + {{/* PostgreSQL hostname (deprecated - used only for legacy DATABASE_HOST env var) */}} diff --git a/hosting/k8s/helm/templates/webapp.yaml b/hosting/k8s/helm/templates/webapp.yaml index f9f59c363f..779aeaa4d2 100644 --- a/hosting/k8s/helm/templates/webapp.yaml +++ b/hosting/k8s/helm/templates/webapp.yaml @@ -67,8 +67,9 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} initContainers: - - name: init-shared - image: busybox:1.35 + - name: volume-permissions + image: {{ include "trigger-v4.webapp.volumePermissions.image" . }} + imagePullPolicy: {{ .Values.webapp.volumePermissions.image.pullPolicy }} command: ['sh', '-c', 'mkdir -p /home/node/shared'] securityContext: runAsUser: 1000 @@ -77,7 +78,8 @@ spec: mountPath: /home/node/shared containers: - name: token-syncer - image: bitnami/kubectl:1.28 + image: {{ include "trigger-v4.webapp.tokenSyncer.image" . }} + imagePullPolicy: {{ .Values.webapp.tokenSyncer.image.pullPolicy }} securityContext: runAsUser: 1000 runAsNonRoot: true @@ -419,6 +421,10 @@ spec: tolerations: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.webapp.topologySpreadConstraints }} + topologySpreadConstraints: + {{- tpl (toYaml .) $ | nindent 8 }} + {{- end }} --- apiVersion: v1 kind: Service diff --git a/hosting/k8s/helm/values.yaml b/hosting/k8s/helm/values.yaml index 5f8fdaf046..1a577e8ef4 100644 --- a/hosting/k8s/helm/values.yaml +++ b/hosting/k8s/helm/values.yaml @@ -2,6 +2,9 @@ global: imageRegistry: "" imagePullSecrets: [] storageClass: "" + security: + # Required when using bitnami legacy images + allowInsecureImages: true nameOverride: "" fullnameOverride: "" @@ -45,6 +48,22 @@ webapp: tag: "" # Defaults to Chart.appVersion when empty pullPolicy: IfNotPresent + # Init container for shared directory setup + volumePermissions: + image: + registry: docker.io + repository: busybox + tag: "1.35" + pullPolicy: IfNotPresent + + # Sidecar for token syncing + tokenSyncer: + image: + registry: docker.io + repository: bitnamilegacy/kubectl + tag: "1.28" + pullPolicy: IfNotPresent + # Origin configuration appOrigin: "http://localhost:3040" loginOrigin: "http://localhost:3040" @@ -69,6 +88,8 @@ webapp: nodeSelector: {} tolerations: [] affinity: {} + # Topology Spread Constraints for pod assignment spread across your cluster among failure-domains. Evaluated as a template + topologySpreadConstraints: [] logLevel: "info" gracefulShutdownTimeout: 1000 @@ -359,6 +380,11 @@ supervisor: postgres: deploy: true + image: + # Use bitnami legacy repo + repository: bitnamilegacy/postgresql + # image: docker.io/bitnamilegacy/postgresql:17.5.0-debian-12-r12 + # Bitnami PostgreSQL chart configuration (when deploy: true) auth: enablePostgresUser: true @@ -409,6 +435,11 @@ postgres: redis: deploy: true + image: + # Use bitnami legacy repo + repository: bitnamilegacy/redis + # image: docker.io/bitnamilegacy/redis:8.0.2-debian-12-r4 + # Bitnami Redis chart configuration (when deploy: true) auth: enabled: false @@ -499,6 +530,11 @@ electric: clickhouse: deploy: true + image: + # Use bitnami legacy repo + repository: bitnamilegacy/clickhouse + # image: docker.io/bitnamilegacy/clickhouse:25.6.1-debian-12-r0 + # TLS/Secure connection configuration secure: false # Set to true to use HTTPS and secure connections @@ -561,6 +597,11 @@ s3: # Set to true to deploy internal MinIO (default) deploy: true + image: + # Use bitnami legacy repo + repository: bitnamilegacy/minio + # image: docker.io/bitnamilegacy/minio:2025.6.13-debian-12-r0 + # Bitnami MinIO chart configuration (when deploy: true) # MinIO provides S3-compatible storage when deployed internally auth: diff --git a/internal-packages/emails/emails/components/Footer.tsx b/internal-packages/emails/emails/components/Footer.tsx index 00f128c8be..36ca0b03d8 100644 --- a/internal-packages/emails/emails/components/Footer.tsx +++ b/internal-packages/emails/emails/components/Footer.tsx @@ -1,14 +1,13 @@ -import { Hr, Link, Text } from "@react-email/components"; -import React from "react"; -import { footer, footerAnchor, hr } from "./styles"; +import { Hr, Link, Tailwind, Text } from "@react-email/components"; +import { hr } from "./styles"; export function Footer() { return ( <>
- + ©Trigger.dev, 1111B S Governors Ave STE 6433, Dover, DE 19904 |{" "} - + Trigger.dev diff --git a/internal-packages/emails/emails/components/styles.ts b/internal-packages/emails/emails/components/styles.ts index 7e7db86621..1c9947de9d 100644 --- a/internal-packages/emails/emails/components/styles.ts +++ b/internal-packages/emails/emails/components/styles.ts @@ -26,7 +26,6 @@ export const box = { export const hr = { borderColor: "#272A2E", - margin: "20px 0", }; export const sans = { diff --git a/internal-packages/emails/emails/invite.tsx b/internal-packages/emails/emails/invite.tsx index 56ff815bff..e67ae0dddc 100644 --- a/internal-packages/emails/emails/invite.tsx +++ b/internal-packages/emails/emails/invite.tsx @@ -1,8 +1,18 @@ -import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components"; +import { + Body, + Button, + Container, + Html, + Link, + Preview, + Section, + Text, +} from "@react-email/components"; +import { Tailwind } from "@react-email/tailwind"; import { z } from "zod"; import { Footer } from "./components/Footer"; import { Image } from "./components/Image"; -import { anchor, container, h1, main, paragraphLight } from "./components/styles"; +import { paragraphLight } from "./components/styles"; export const InviteEmailSchema = z.object({ email: z.literal("invite"), @@ -20,30 +30,46 @@ export default function Email({ }: z.infer) { return ( - {`You've been invited to ${orgName}`} - - - {`You've been invited to ${orgName}`} - - {inviterName ?? inviterEmail} has invited you to join their organization on Trigger.dev. - - - Click here to view the invitation - - - Trigger.dev -