From 59adbfbd81eaa64d771a317c86e974dde57530e2 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Fri, 23 Jan 2026 15:30:31 -0500 Subject: [PATCH 01/13] add plain customer cards secret --- apps/webapp/app/env.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 1dc3091f16..716ff8e45c 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -107,6 +107,7 @@ const EnvironmentSchema = z SMTP_PASSWORD: z.string().optional(), PLAIN_API_KEY: z.string().optional(), + PLAIN_CUSTOMER_CARDS_SECRET: z.string().optional(), WORKER_SCHEMA: z.string().default("graphile_worker"), WORKER_CONCURRENCY: z.coerce.number().int().default(10), WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), From bd175b7e390775f6624d0b75a459a0f4fe35582c Mon Sep 17 00:00:00 2001 From: isshaddad Date: Fri, 23 Jan 2026 15:33:32 -0500 Subject: [PATCH 02/13] created plain customer cards post route --- .../app/routes/api.v1.plain.customer-cards.ts | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 apps/webapp/app/routes/api.v1.plain.customer-cards.ts diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts new file mode 100644 index 0000000000..2cd9af9f1d --- /dev/null +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -0,0 +1,393 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { uiComponent } from "@team-plain/typescript-sdk"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; + +// Schema for the request body from Plain +const PlainCustomerCardRequestSchema = z.object({ + cardKeys: z.array(z.string()), + customer: z.object({ + id: z.string(), + email: z.string().optional(), + externalId: z.string().optional(), + }), + thread: z + .object({ + id: z.string(), + }) + .optional(), +}); + +// Authenticate the request from Plain +function authenticatePlainRequest(request: Request): boolean { + const authHeader = request.headers.get("Authorization"); + const expectedSecret = env.PLAIN_CUSTOMER_CARDS_SECRET; + + if (!expectedSecret) { + logger.warn("PLAIN_CUSTOMER_CARDS_SECRET not configured"); + return false; + } + + if (!authHeader) { + return false; + } + + // Support both "Bearer " and plain token formats + const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader; + + return token === expectedSecret; +} + +export async function action({ request }: ActionFunctionArgs) { + // Only accept POST requests + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + // Authenticate the request + if (!authenticatePlainRequest(request)) { + logger.warn("Unauthorized Plain customer card request", { + headers: Object.fromEntries(request.headers.entries()), + }); + return json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + // Parse and validate the request body + const body = await request.json(); + const parsed = PlainCustomerCardRequestSchema.safeParse(body); + + if (!parsed.success) { + logger.warn("Invalid Plain customer card request", { + errors: parsed.error.errors, + body, + }); + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const { customer, cardKeys } = parsed.data; + + // Look up the user by externalId (which is User.id) + let user = null; + if (customer.externalId) { + user = await prisma.user.findUnique({ + where: { id: customer.externalId }, + include: { + orgMemberships: { + include: { + organization: { + include: { + projects: { + where: { deletedAt: null }, + take: 10, // Limit to recent projects + orderBy: { createdAt: "desc" }, + }, + }, + }, + }, + }, + }, + }); + } else if (customer.email) { + // Fallback to email lookup if externalId is not provided + user = await prisma.user.findUnique({ + where: { email: customer.email }, + include: { + orgMemberships: { + include: { + organization: { + include: { + projects: { + where: { deletedAt: null }, + take: 10, + orderBy: { createdAt: "desc" }, + }, + }, + }, + }, + }, + }, + }); + } + + // If user not found, return empty cards + if (!user) { + logger.info("User not found for Plain customer card request", { + customerId: customer.id, + externalId: customer.externalId, + email: customer.email, + }); + return json({ cards: [] }); + } + + // Build cards based on requested cardKeys + const cards = []; + + for (const cardKey of cardKeys) { + switch (cardKey) { + case "account-details": { + // Build the impersonate URL + const impersonateUrl = `${env.APP_ORIGIN || "https://cloud.trigger.dev"}/admin?impersonate=${user.id}`; + + cards.push({ + key: "account-details", + timeToLiveSeconds: 300, // Cache for 5 minutes + components: [ + uiComponent.container({ + components: [ + uiComponent.text({ + text: "Account Details", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.row({ + left: uiComponent.text({ + text: "User ID", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.copyButton({ + textToCopy: user.id, + buttonLabel: "Copy", + }), + }), + uiComponent.spacer({ spacerSize: "S" }), + uiComponent.row({ + left: uiComponent.text({ + text: "Email", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.text({ + text: user.email, + textSize: "S", + textColor: "NORMAL", + }), + }), + uiComponent.spacer({ spacerSize: "S" }), + uiComponent.row({ + left: uiComponent.text({ + text: "Name", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.text({ + text: user.name || user.displayName || "N/A", + textSize: "S", + textColor: "NORMAL", + }), + }), + uiComponent.spacer({ spacerSize: "S" }), + uiComponent.row({ + left: uiComponent.text({ + text: "Admin", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.badge({ + badgeLabel: user.admin ? "Yes" : "No", + badgeColor: user.admin ? "BLUE" : "GRAY", + }), + }), + uiComponent.spacer({ spacerSize: "S" }), + uiComponent.row({ + left: uiComponent.text({ + text: "Member Since", + textSize: "S", + textColor: "MUTED", + }), + right: uiComponent.text({ + text: new Date(user.createdAt).toLocaleDateString(), + textSize: "S", + textColor: "NORMAL", + }), + }), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.divider(), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.linkButton({ + buttonLabel: "Impersonate User", + buttonUrl: impersonateUrl, + buttonStyle: "PRIMARY", + }), + ], + }), + ], + }); + break; + } + + case "organizations": { + if (user.orgMemberships.length === 0) { + cards.push({ + key: "organizations", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + components: [ + uiComponent.text({ + text: "Organizations", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.text({ + text: "No organizations found", + textSize: "S", + textColor: "MUTED", + }), + ], + }), + ], + }); + break; + } + + const orgComponents = user.orgMemberships.flatMap((membership, index) => { + const org = membership.organization; + const projectCount = org.projects.length; + + return [ + ...(index > 0 ? [uiComponent.divider()] : []), + uiComponent.text({ + text: org.title, + textSize: "M", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "XS" }), + uiComponent.row({ + left: uiComponent.badge({ + badgeLabel: membership.role, + badgeColor: membership.role === "ADMIN" ? "BLUE" : "GRAY", + }), + right: uiComponent.text({ + text: `${projectCount} project${projectCount !== 1 ? "s" : ""}`, + textSize: "S", + textColor: "MUTED", + }), + }), + uiComponent.spacer({ spacerSize: "XS" }), + uiComponent.linkButton({ + buttonLabel: "View in Dashboard", + buttonUrl: `https://cloud.trigger.dev/orgs/${org.slug}`, + buttonStyle: "SECONDARY", + }), + ]; + }); + + cards.push({ + key: "organizations", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + components: [ + uiComponent.text({ + text: "Organizations", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "M" }), + ...orgComponents, + ], + }), + ], + }); + break; + } + + case "projects": { + const allProjects = user.orgMemberships.flatMap((membership) => + membership.organization.projects.map((project) => ({ + ...project, + orgSlug: membership.organization.slug, + })) + ); + + if (allProjects.length === 0) { + cards.push({ + key: "projects", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + components: [ + uiComponent.text({ + text: "Projects", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "M" }), + uiComponent.text({ + text: "No projects found", + textSize: "S", + textColor: "MUTED", + }), + ], + }), + ], + }); + break; + } + + const projectComponents = allProjects.slice(0, 10).flatMap((project, index) => { + return [ + ...(index > 0 ? [uiComponent.divider()] : []), + uiComponent.text({ + text: project.name, + textSize: "M", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "XS" }), + uiComponent.row({ + left: uiComponent.badge({ + badgeLabel: project.version, + badgeColor: project.version === "V3" ? "GREEN" : "GRAY", + }), + right: uiComponent.linkButton({ + buttonLabel: "View", + buttonUrl: `https://cloud.trigger.dev/orgs/${project.orgSlug}/projects/${project.slug}`, + buttonStyle: "SECONDARY", + }), + }), + ]; + }); + + cards.push({ + key: "projects", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + components: [ + uiComponent.text({ + text: "Projects", + textSize: "L", + textColor: "NORMAL", + }), + uiComponent.spacer({ spacerSize: "M" }), + ...projectComponents, + ], + }), + ], + }); + break; + } + + default: + // Unknown card key - skip it + logger.info("Unknown card key requested", { cardKey }); + break; + } + } + + return json({ cards }); + } catch (error) { + logger.error("Error processing Plain customer card request", { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + return json({ error: "Internal server error" }, { status: 500 }); + } +} From 8f025b067e15084e5fdc934d22d3c2b86681236a Mon Sep 17 00:00:00 2001 From: isshaddad Date: Fri, 23 Jan 2026 15:33:59 -0500 Subject: [PATCH 03/13] added plain customer card post route to pathWhiteList --- apps/webapp/app/services/apiRateLimit.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index dee0a889d1..8f40da009a 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -51,6 +51,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ "/api/v1/authorization-code", "/api/v1/token", "/api/v1/usage/ingest", + "/api/v1/plain/customer-cards", /^\/api\/v1\/tasks\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/tasks/$id/callback/$secret /^\/api\/v1\/runs\/[^\/]+\/tasks\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/runs/$runId/tasks/$id/callback/$secret /^\/api\/v1\/http-endpoints\/[^\/]+\/env\/[^\/]+\/[^\/]+$/, // /api/v1/http-endpoints/$httpEndpointId/env/$envType/$shortcode From 5f2fd7b2b8539d78c7984224c8654c81813de443 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Fri, 23 Jan 2026 15:35:13 -0500 Subject: [PATCH 04/13] handle immpersonation request from plain customer cards --- apps/webapp/app/routes/admin._index.tsx | 29 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index aafb818002..10606593f9 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -21,8 +21,7 @@ import { } from "~/components/primitives/Table"; import { useUser } from "~/hooks/useUser"; import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; -import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server"; -import { requireUserId } from "~/services/session.server"; +import { requireUser, requireUserId } from "~/services/session.server"; import { createSearchParams } from "~/utils/searchParams"; export const SearchParams = z.object({ @@ -32,7 +31,29 @@ export const SearchParams = z.object({ export type SearchParams = z.infer; +const FormSchema = z.object({ id: z.string() }); + +async function handleImpersonationRequest( + request: Request, + userId: string +): Promise { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + return redirectWithImpersonation(request, userId, "/"); +} + export const loader = async ({ request, params }: LoaderFunctionArgs) => { + // Check if this is an impersonation request via query parameter (e.g., from Plain customer cards) + const url = new URL(request.url); + const impersonateUserId = url.searchParams.get("impersonate"); + + if (impersonateUserId) { + return handleImpersonationRequest(request, impersonateUserId); + } + + // Normal loader logic for admin dashboard const userId = await requireUserId(request); const searchParams = createSearchParams(request.url, SearchParams); @@ -44,8 +65,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson(result); }; -const FormSchema = z.object({ id: z.string() }); - export async function action({ request }: ActionFunctionArgs) { if (request.method.toLowerCase() !== "post") { return new Response("Method not allowed", { status: 405 }); @@ -54,7 +73,7 @@ export async function action({ request }: ActionFunctionArgs) { const payload = Object.fromEntries(await request.formData()); const { id } = FormSchema.parse(payload); - return redirectWithImpersonation(request, id, "/"); + return handleImpersonationRequest(request, id); } export default function AdminDashboardRoute() { From 6356fb1c427f6ba8414fd7db9fb7414bbd9144e7 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Mon, 26 Jan 2026 13:17:30 -0500 Subject: [PATCH 05/13] type fixes for plain object --- apps/webapp/app/routes/admin._index.tsx | 4 +- .../app/routes/api.v1.plain.customer-cards.ts | 306 ++++++++++-------- 2 files changed, 172 insertions(+), 138 deletions(-) diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index 10606593f9..2ddca5b50f 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -119,14 +119,14 @@ export default function AdminDashboardRoute() { No users found for search ) : ( - users.map((user) => { + users.map((user: (typeof users)[0]) => { return ( - {user.orgMemberships.map((org) => ( + {user.orgMemberships.map((org: (typeof user.orgMemberships)[0]) => ( { - const org = membership.organization; - const projectCount = org.projects.length; + const orgComponents = user.orgMemberships.flatMap( + ( + membership: (typeof user.orgMemberships)[0], + index: number + ) => { + const org = membership.organization; + const projectCount = org.projects.length; - return [ - ...(index > 0 ? [uiComponent.divider()] : []), - uiComponent.text({ - text: org.title, - textSize: "M", - textColor: "NORMAL", - }), - uiComponent.spacer({ spacerSize: "XS" }), - uiComponent.row({ - left: uiComponent.badge({ - badgeLabel: membership.role, - badgeColor: membership.role === "ADMIN" ? "BLUE" : "GRAY", + return [ + ...(index > 0 ? [uiComponent.divider({ spacingSize: "M" })] : []), + uiComponent.text({ + text: org.title, + size: "M", + color: "NORMAL", }), - right: uiComponent.text({ - text: `${projectCount} project${projectCount !== 1 ? "s" : ""}`, - textSize: "S", - textColor: "MUTED", + uiComponent.spacer({ size: "XS" }), + uiComponent.row({ + mainContent: [ + uiComponent.badge({ + label: membership.role, + color: membership.role === "ADMIN" ? "BLUE" : "GREY", + }), + ], + asideContent: [ + uiComponent.text({ + text: `${projectCount} project${projectCount !== 1 ? "s" : ""}`, + size: "S", + color: "MUTED", + }), + ], }), - }), - uiComponent.spacer({ spacerSize: "XS" }), - uiComponent.linkButton({ - buttonLabel: "View in Dashboard", - buttonUrl: `https://cloud.trigger.dev/orgs/${org.slug}`, - buttonStyle: "SECONDARY", - }), - ]; - }); + uiComponent.spacer({ size: "XS" }), + uiComponent.linkButton({ + label: "View in Dashboard", + url: `https://cloud.trigger.dev/@/orgs/${org.slug}`, + }), + ]; + } + ); cards.push({ key: "organizations", timeToLiveSeconds: 300, components: [ uiComponent.container({ - components: [ + content: [ uiComponent.text({ text: "Organizations", - textSize: "L", - textColor: "NORMAL", + size: "L", + color: "NORMAL", }), - uiComponent.spacer({ spacerSize: "M" }), + uiComponent.spacer({ size: "M" }), ...orgComponents, ], }), @@ -311,19 +337,19 @@ export async function action({ request }: ActionFunctionArgs) { cards.push({ key: "projects", timeToLiveSeconds: 300, - components: [ - uiComponent.container({ - components: [ - uiComponent.text({ - text: "Projects", - textSize: "L", - textColor: "NORMAL", + components: [ + uiComponent.container({ + content: [ + uiComponent.text({ + text: "Projects", + size: "L", + color: "NORMAL", }), - uiComponent.spacer({ spacerSize: "M" }), + uiComponent.spacer({ size: "M" }), uiComponent.text({ text: "No projects found", - textSize: "S", - textColor: "MUTED", + size: "S", + color: "MUTED", }), ], }), @@ -332,41 +358,49 @@ export async function action({ request }: ActionFunctionArgs) { break; } - const projectComponents = allProjects.slice(0, 10).flatMap((project, index) => { - return [ - ...(index > 0 ? [uiComponent.divider()] : []), - uiComponent.text({ - text: project.name, - textSize: "M", - textColor: "NORMAL", - }), - uiComponent.spacer({ spacerSize: "XS" }), - uiComponent.row({ - left: uiComponent.badge({ - badgeLabel: project.version, - badgeColor: project.version === "V3" ? "GREEN" : "GRAY", + const projectComponents = allProjects.slice(0, 10).flatMap( + ( + project: typeof allProjects[0] & { orgSlug: string }, + index: number + ) => { + return [ + ...(index > 0 ? [uiComponent.divider({ spacingSize: "M" })] : []), + uiComponent.text({ + text: project.name, + size: "M", + color: "NORMAL", }), - right: uiComponent.linkButton({ - buttonLabel: "View", - buttonUrl: `https://cloud.trigger.dev/orgs/${project.orgSlug}/projects/${project.slug}`, - buttonStyle: "SECONDARY", + uiComponent.spacer({ size: "XS" }), + uiComponent.row({ + mainContent: [ + uiComponent.badge({ + label: project.version, + color: project.version === "V3" ? "GREEN" : "GREY", + }), + ], + asideContent: [ + uiComponent.linkButton({ + label: "View", + url: `https://cloud.trigger.dev/orgs/${project.orgSlug}/projects/${project.slug}`, + }), + ], }), - }), - ]; - }); + ]; + } + ); cards.push({ key: "projects", timeToLiveSeconds: 300, components: [ uiComponent.container({ - components: [ + content: [ uiComponent.text({ text: "Projects", - textSize: "L", - textColor: "NORMAL", + size: "L", + color: "NORMAL", }), - uiComponent.spacer({ spacerSize: "M" }), + uiComponent.spacer({ size: "M" }), ...projectComponents, ], }), From fb4cd3fdbe2f7c6772ee544cf87cc057fcc49734 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Mon, 26 Jan 2026 13:43:12 -0500 Subject: [PATCH 06/13] remove admin field --- .../app/routes/api.v1.plain.customer-cards.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index 471e0da338..3db99fb66c 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -193,22 +193,6 @@ export async function action({ request }: ActionFunctionArgs) { ], }), uiComponent.spacer({ size: "S" }), - uiComponent.row({ - mainContent: [ - uiComponent.text({ - text: "Admin", - size: "S", - color: "MUTED", - }), - ], - asideContent: [ - uiComponent.badge({ - label: user.admin ? "Yes" : "No", - color: user.admin ? "BLUE" : "GREY", - }), - ], - }), - uiComponent.spacer({ size: "S" }), uiComponent.row({ mainContent: [ uiComponent.text({ From 5cfc7df9b849d67aed00784f031bb2cca37cf7db Mon Sep 17 00:00:00 2001 From: isshaddad Date: Mon, 26 Jan 2026 13:55:29 -0500 Subject: [PATCH 07/13] rename headers from plain --- apps/webapp/app/routes/api.v1.plain.customer-cards.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index 3db99fb66c..2366d1c92f 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -23,7 +23,7 @@ const PlainCustomerCardRequestSchema = z.object({ // Authenticate the request from Plain function authenticatePlainRequest(request: Request): boolean { - const authHeader = request.headers.get("PLAIN_AUTH"); + const authHeader = request.headers.get("Authorization"); const expectedSecret = env.PLAIN_CUSTOMER_CARDS_SECRET; if (!expectedSecret) { logger.warn("PLAIN_CUSTOMER_CARDS_SECRET not configured"); From afead1e1a7c306b55b9505bfe26ccb2b7f32a433 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Mon, 26 Jan 2026 15:01:02 -0500 Subject: [PATCH 08/13] useTypedLoaderData type fix --- apps/webapp/app/routes/admin._index.tsx | 2 +- .../app/routes/api.v1.plain.customer-cards.ts | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index 2ddca5b50f..d4421a1076 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -78,7 +78,7 @@ export async function action({ request }: ActionFunctionArgs) { export default function AdminDashboardRoute() { const user = useUser(); - const { users, filters, page, pageCount } = useTypedLoaderData(); + const { users, filters, page, pageCount } = useTypedLoaderData() as any; return (
" and plain token formats const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader; - return token === expectedSecret; + // Use constant-time comparison to prevent timing attacks + const encoder = new TextEncoder(); + const tokenBuffer = encoder.encode(token); + const secretBuffer = encoder.encode(expectedSecret); + if (tokenBuffer.byteLength !== secretBuffer.byteLength) { + return false; + } + return timingSafeEqual(tokenBuffer, secretBuffer); } export async function action({ request }: ActionFunctionArgs) { @@ -76,6 +84,9 @@ export async function action({ request }: ActionFunctionArgs) { where: { id: customer.externalId }, include: { orgMemberships: { + where: { + organization: { deletedAt: null }, + }, include: { organization: { include: { @@ -96,6 +107,9 @@ export async function action({ request }: ActionFunctionArgs) { where: { email: customer.email }, include: { orgMemberships: { + where: { + organization: { deletedAt: null }, + }, include: { organization: { include: { @@ -129,7 +143,7 @@ export async function action({ request }: ActionFunctionArgs) { switch (cardKey) { case "account-details": { // Build the impersonate URL - const impersonateUrl = `${env.APP_ORIGIN || "https://cloud.trigger.dev"}/admin?impersonate=${user.id}`; + const impersonateUrl = `${env.APP_ORIGIN}/admin?impersonate=${user.id}`; cards.push({ key: "account-details", @@ -283,7 +297,7 @@ export async function action({ request }: ActionFunctionArgs) { uiComponent.spacer({ size: "XS" }), uiComponent.linkButton({ label: "View in Dashboard", - url: `https://cloud.trigger.dev/@/orgs/${org.slug}`, + url: `${env.APP_ORIGIN}/@/orgs/${org.slug}`, }), ]; } @@ -365,7 +379,7 @@ export async function action({ request }: ActionFunctionArgs) { asideContent: [ uiComponent.linkButton({ label: "View", - url: `https://cloud.trigger.dev/orgs/${project.orgSlug}/projects/${project.slug}`, + url: `${env.APP_ORIGIN}/orgs/${project.orgSlug}/projects/${project.slug}`, }), ], }), From 270c7530dbc9f11aba7af6948a7c98fb4dbffd14 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Mon, 26 Jan 2026 15:09:21 -0500 Subject: [PATCH 09/13] csrf protection --- apps/webapp/app/routes/admin._index.tsx | 19 ++++ .../app/routes/api.v1.plain.customer-cards.ts | 7 +- .../app/services/impersonation.server.ts | 90 +++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index d4421a1076..66f12fb9ff 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -22,7 +22,11 @@ import { import { useUser } from "~/hooks/useUser"; import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; import { requireUser, requireUserId } from "~/services/session.server"; +import { + validateAndConsumeImpersonationToken, +} from "~/services/impersonation.server"; import { createSearchParams } from "~/utils/searchParams"; +import { logger } from "~/services/logger.server"; export const SearchParams = z.object({ page: z.coerce.number().optional(), @@ -48,8 +52,23 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // Check if this is an impersonation request via query parameter (e.g., from Plain customer cards) const url = new URL(request.url); const impersonateUserId = url.searchParams.get("impersonate"); + const impersonationToken = url.searchParams.get("impersonationToken"); if (impersonateUserId) { + // Require both userId and token for GET-based impersonation + if (!impersonationToken) { + logger.warn("Impersonation request missing token"); + return redirect("/"); + } + + // Validate and consume the token (prevents replay attacks) + const validatedUserId = await validateAndConsumeImpersonationToken(impersonationToken); + + if (!validatedUserId || validatedUserId !== impersonateUserId) { + logger.warn("Invalid or expired impersonation token"); + return redirect("/"); + } + return handleImpersonationRequest(request, impersonateUserId); } diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index 4db239d320..afc76c4376 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; +import { generateImpersonationToken } from "~/services/impersonation.server"; // Schema for the request body from Plain const PlainCustomerCardRequestSchema = z.object({ @@ -142,8 +143,10 @@ export async function action({ request }: ActionFunctionArgs) { for (const cardKey of cardKeys) { switch (cardKey) { case "account-details": { - // Build the impersonate URL - const impersonateUrl = `${env.APP_ORIGIN}/admin?impersonate=${user.id}`; + // Generate a signed one-time token for impersonation + const impersonationToken = await generateImpersonationToken(user.id); + // Build the impersonate URL with token for CSRF protection + const impersonateUrl = `${env.APP_ORIGIN}/admin?impersonate=${user.id}&impersonationToken=${encodeURIComponent(impersonationToken)}`; cards.push({ key: "account-details", diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts index 78c771528b..4a3a676d1a 100644 --- a/apps/webapp/app/services/impersonation.server.ts +++ b/apps/webapp/app/services/impersonation.server.ts @@ -1,5 +1,9 @@ import { createCookieSessionStorage, type Session } from "@remix-run/node"; +import { SignJWT, jwtVerify, errors } from "jose"; +import { singleton } from "~/utils/singleton"; +import { createRedisClient, type RedisClient } from "~/redis.server"; import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; export const impersonationSessionStorage = createCookieSessionStorage({ cookie: { @@ -42,3 +46,89 @@ export async function clearImpersonationId(request: Request) { return session; } + +// Impersonation token utilities for CSRF protection +const IMPERSONATION_TOKEN_EXPIRY_SECONDS = 5 * 60; // 5 minutes + +function getImpersonationTokenSecret(): Uint8Array { + return new TextEncoder().encode(env.SESSION_SECRET); +} + +function getImpersonationTokenRedisClient(): RedisClient { + return singleton( + "impersonationTokenRedis", + () => + createRedisClient("impersonation:token", { + host: env.CACHE_REDIS_HOST, + port: env.CACHE_REDIS_PORT, + username: env.CACHE_REDIS_USERNAME, + password: env.CACHE_REDIS_PASSWORD, + tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true", + clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1", + keyPrefix: "impersonation:token:", + }) + ); +} + +/** + * Generate a signed one-time impersonation token for a user + */ +export async function generateImpersonationToken(userId: string): Promise { + const secret = getImpersonationTokenSecret(); + const now = Math.floor(Date.now() / 1000); + + const token = await new SignJWT({ userId }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + IMPERSONATION_TOKEN_EXPIRY_SECONDS) + .setIssuer("https://trigger.dev") + .setAudience("https://trigger.dev/admin") + .sign(secret); + + return token; +} + +/** + * Validate and consume an impersonation token (prevents replay attacks) + */ +export async function validateAndConsumeImpersonationToken( + token: string +): Promise { + try { + const secret = getImpersonationTokenSecret(); + + // Verify the token signature and expiration + const { payload } = await jwtVerify(token, secret, { + issuer: "https://trigger.dev", + audience: "https://trigger.dev/admin", + }); + + const userId = payload.userId as string | undefined; + if (!userId || typeof userId !== "string") { + return undefined; + } + + // Check if token has already been used (prevent replay attacks) + const redis = getImpersonationTokenRedisClient(); + const tokenKey = token; + + // Try to set the key with NX (only if not exists) and expiration + // This atomically marks the token as used + const result = await redis.set(tokenKey, "1", "EX", IMPERSONATION_TOKEN_EXPIRY_SECONDS, "NX"); + + if (result !== "OK") { + // Token was already used + return undefined; + } + + return userId; + } catch (error) { + if (error instanceof errors.JWTExpired || error instanceof errors.JWTInvalid) { + return undefined; + } + logger.error("Error validating impersonation token", { + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } +} From d2b9ca1a1fcec5e6acfc2e6cfe8a84fcec0d7587 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Mon, 26 Jan 2026 15:30:30 -0500 Subject: [PATCH 10/13] JSON parsing fix --- .../app/routes/api.v1.plain.customer-cards.ts | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index afc76c4376..d0d5e2ee18 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -63,18 +63,34 @@ export async function action({ request }: ActionFunctionArgs) { return json({ error: "Unauthorized" }, { status: 401 }); } + // Parse JSON with separate error handling for malformed JSON + let body: unknown; try { - // Parse and validate the request body - const body = await request.json(); - const parsed = PlainCustomerCardRequestSchema.safeParse(body); - - if (!parsed.success) { - logger.warn("Invalid Plain customer card request", { - errors: parsed.error.errors, - body, + body = await request.json(); + } catch (error) { + // Handle JSON parsing errors as client errors (400) instead of server errors (500) + if (error instanceof SyntaxError || error instanceof TypeError) { + logger.warn("Malformed JSON in Plain customer card request", { + error: error.message, }); - return json({ error: "Invalid request body" }, { status: 400 }); + return json({ error: "Invalid JSON in request body" }, { status: 400 }); } + // Re-throw unexpected errors to be caught by outer catch + throw error; + } + + // Validate the request body schema + const parsed = PlainCustomerCardRequestSchema.safeParse(body); + + if (!parsed.success) { + logger.warn("Invalid Plain customer card request", { + errors: parsed.error.errors, + body, + }); + return json({ error: "Invalid request body" }, { status: 400 }); + } + + try { const { customer, cardKeys } = parsed.data; From 0d9f0572fe807a9bd61810dc4e9e5f06c5b8d11c Mon Sep 17 00:00:00 2001 From: isshaddad Date: Mon, 26 Jan 2026 15:57:00 -0500 Subject: [PATCH 11/13] one time impersonation fix --- .../app/routes/api.v1.plain.customer-cards.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index d0d5e2ee18..48514436e9 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -23,6 +23,19 @@ const PlainCustomerCardRequestSchema = z.object({ .optional(), }); +// Sanitize headers to remove sensitive information before logging +function sanitizeHeaders(request: Request, skipHeaders = ["authorization", "cookie"]): Partial> { + const sanitizedHeaders: Partial> = {}; + + for (const [key, value] of request.headers.entries()) { + if (!skipHeaders.includes(key.toLowerCase())) { + sanitizedHeaders[key] = value; + } + } + + return sanitizedHeaders; +} + // Authenticate the request from Plain function authenticatePlainRequest(request: Request): boolean { const authHeader = request.headers.get("Authorization"); @@ -58,7 +71,7 @@ export async function action({ request }: ActionFunctionArgs) { // Authenticate the request if (!authenticatePlainRequest(request)) { logger.warn("Unauthorized Plain customer card request", { - headers: Object.fromEntries(request.headers.entries()), + headers: sanitizeHeaders(request), }); return json({ error: "Unauthorized" }, { status: 401 }); } @@ -166,7 +179,7 @@ export async function action({ request }: ActionFunctionArgs) { cards.push({ key: "account-details", - timeToLiveSeconds: 300, // Cache for 5 minutes + timeToLiveSeconds: 10, components: [ uiComponent.container({ content: [ From 94243c96ded11ba266b3b4489f158040f561eddc Mon Sep 17 00:00:00 2001 From: isshaddad Date: Mon, 26 Jan 2026 16:01:35 -0500 Subject: [PATCH 12/13] stable customer identifier schema --- .../app/routes/api.v1.plain.customer-cards.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index 48514436e9..85df43dbba 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -11,11 +11,19 @@ import { generateImpersonationToken } from "~/services/impersonation.server"; // Schema for the request body from Plain const PlainCustomerCardRequestSchema = z.object({ cardKeys: z.array(z.string()), - customer: z.object({ - id: z.string(), - email: z.string().optional(), - externalId: z.string().optional(), - }), + customer: z + .object({ + id: z.string(), + email: z.string().optional(), + externalId: z.string().optional(), + }) + .refine( + (data) => data.email || data.externalId, + { + message: "Either customer.email or customer.externalId must be provided", + path: ["customer"], + } + ), thread: z .object({ id: z.string(), @@ -320,7 +328,7 @@ export async function action({ request }: ActionFunctionArgs) { ], asideContent: [ uiComponent.text({ - text: `${projectCount} project${projectCount !== 1 ? "s" : ""}`, + text: `${projectCount} recent project${projectCount !== 1 ? "s" : ""}`, size: "S", color: "MUTED", }), From 39ac6152947538e2c2831aa33dc5ccac6ff4d00b Mon Sep 17 00:00:00 2001 From: isshaddad Date: Mon, 26 Jan 2026 18:28:25 -0500 Subject: [PATCH 13/13] set plain variables as env variables --- apps/webapp/app/env.server.ts | 2 ++ .../app/routes/api.v1.plain.customer-cards.ts | 17 ++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 716ff8e45c..741758a7d9 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -108,6 +108,8 @@ const EnvironmentSchema = z PLAIN_API_KEY: z.string().optional(), PLAIN_CUSTOMER_CARDS_SECRET: z.string().optional(), + PLAIN_CUSTOMER_CARDS_KEY: z.string().optional(), + PLAIN_CUSTOMER_CARDS_HEADERS: z.string().optional(), WORKER_SCHEMA: z.string().default("graphile_worker"), WORKER_CONCURRENCY: z.coerce.number().int().default(10), WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), diff --git a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts index 85df43dbba..a33d607654 100644 --- a/apps/webapp/app/routes/api.v1.plain.customer-cards.ts +++ b/apps/webapp/app/routes/api.v1.plain.customer-cards.ts @@ -31,12 +31,13 @@ const PlainCustomerCardRequestSchema = z.object({ .optional(), }); -// Sanitize headers to remove sensitive information before logging -function sanitizeHeaders(request: Request, skipHeaders = ["authorization", "cookie"]): Partial> { +function sanitizeHeaders(request: Request, skipHeaders?: string[]): Partial> { + const authHeaderName = (env.PLAIN_CUSTOMER_CARDS_HEADERS || "Authorization").toLowerCase(); + const defaultSkipHeaders = skipHeaders || [authHeaderName, "cookie"]; const sanitizedHeaders: Partial> = {}; for (const [key, value] of request.headers.entries()) { - if (!skipHeaders.includes(key.toLowerCase())) { + if (!defaultSkipHeaders.includes(key.toLowerCase())) { sanitizedHeaders[key] = value; } } @@ -46,7 +47,8 @@ function sanitizeHeaders(request: Request, skipHeaders = ["authorization", "cook // Authenticate the request from Plain function authenticatePlainRequest(request: Request): boolean { - const authHeader = request.headers.get("Authorization"); + const authHeaderName = env.PLAIN_CUSTOMER_CARDS_HEADERS || "Authorization"; + const authHeader = request.headers.get(authHeaderName); const expectedSecret = env.PLAIN_CUSTOMER_CARDS_SECRET; if (!expectedSecret) { logger.warn("PLAIN_CUSTOMER_CARDS_SECRET not configured"); @@ -177,17 +179,18 @@ export async function action({ request }: ActionFunctionArgs) { // Build cards based on requested cardKeys const cards = []; + const accountDetailsKey = env.PLAIN_CUSTOMER_CARDS_KEY || "account-details"; for (const cardKey of cardKeys) { switch (cardKey) { - case "account-details": { + case accountDetailsKey: { // Generate a signed one-time token for impersonation const impersonationToken = await generateImpersonationToken(user.id); // Build the impersonate URL with token for CSRF protection const impersonateUrl = `${env.APP_ORIGIN}/admin?impersonate=${user.id}&impersonationToken=${encodeURIComponent(impersonationToken)}`; cards.push({ - key: "account-details", - timeToLiveSeconds: 10, + key: accountDetailsKey, + timeToLiveSeconds: 15, components: [ uiComponent.container({ content: [