diff --git a/.changeset/chat-idle-wait-traces.md b/.changeset/chat-idle-wait-traces.md deleted file mode 100644 index bd6fb69c1ba..00000000000 --- a/.changeset/chat-idle-wait-traces.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Show warm idle time and durable waits separately in chat agent traces. Durable waits now open the waitpoint inspector while waiting and after completion. Message span names are shorter, and repeated session IDs no longer crowd message-wait and default output-stream spans. diff --git a/.changeset/outer-compaction-last-step-usage.md b/.changeset/outer-compaction-last-step-usage.md deleted file mode 100644 index 78ea4c344e1..00000000000 --- a/.changeset/outer-compaction-last-step-usage.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -chat.agent: the between-turns compaction check now receives the last step's token usage (the context the model actually held) instead of the turn's sum over every tool-calling step, so a single tool-using turn no longer compacts a short conversation. The summed figure is still available as `turnUsage` on the event. A head-start handover whose pending tool call completes under the same message id now replaces its spliced partial in the model lane directly instead of falling back to a full reconversion. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bcb7dfc5334..3c0b5cbd670 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,10 +5,12 @@ on: branches: - main paths: + - ".github/workflows/docs.yml" - "docs/**" pull_request: types: [opened, synchronize, reopened] paths: + - ".github/workflows/docs.yml" - "docs/**" concurrency: @@ -30,15 +32,22 @@ jobs: with: persist-credentials: false - - name: 📦 Cache npm - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.npm - key: | - ${{ runner.os }}-mintlify - restore-keys: | - ${{ runner.os }}-mintlify + - name: 📦 Install Mintlify + working-directory: ${{ runner.temp }} + env: + COREPACK_HOME: ${{ runner.temp }}/mintlify-corepack + NPM_CONFIG_CACHE: ${{ runner.temp }}/mintlify-npm-cache + NPM_CONFIG_GLOBALCONFIG: /dev/null + NPM_CONFIG_USERCONFIG: /dev/null + PNPM_HOME: ${{ runner.temp }}/mintlify-pnpm + run: | + mkdir -p "$RUNNER_TEMP/mintlify-tool" + printf '{"private":true}\n' >"$RUNNER_TEMP/mintlify-tool/package.json" + corepack pnpm@10.33.2 --dir "$RUNNER_TEMP/mintlify-tool" add \ + --save-exact \ + --store-dir "$RUNNER_TEMP/mintlify-pnpm-store" \ + --registry=https://registry.npmjs.org/ \ + mintlify@4.0.393 - name: 🔗 Check for broken links - run: npx mintlify@4.0.393 broken-links + run: '"$RUNNER_TEMP/mintlify-tool/node_modules/.bin/mintlify" broken-links' diff --git a/.server-changes/billing-limit-banner-no-limit-button.md b/.server-changes/billing-limit-banner-no-limit-button.md deleted file mode 100644 index e12e37d5674..00000000000 --- a/.server-changes/billing-limit-banner-no-limit-button.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -The banner that prompts you to set a billing limit now lets you choose to run without one, so you don't have to open your billing limit settings to dismiss it. diff --git a/.server-changes/invite-email-case-insensitive.md b/.server-changes/invite-email-case-insensitive.md deleted file mode 100644 index 25e90aedcca..00000000000 --- a/.server-changes/invite-email-case-insensitive.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Org member invites now match emails case-insensitively, so an invite whose email casing differs from the invitee's account email can be accepted. diff --git a/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.render.test.ts b/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.render.test.ts new file mode 100644 index 00000000000..da71551a2df --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.render.test.ts @@ -0,0 +1,45 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import { DiagnosisDocsAction } from "./RunDiagnosisCard"; + +function markup(label: string) { + return renderToStaticMarkup( + createElement( + OperatingSystemContextProvider, + { platform: "mac" }, + createElement( + ShortcutsProvider, + null, + createElement(DiagnosisDocsAction, { + action: { + kind: "docs", + label, + to: "https://example.com/help", + destinationHost: "example.com", + }, + }) + ) + ) + ); +} + +describe("diagnosis documentation actions", () => { + it("bounds and isolates an untrusted label without hiding the destination host", () => { + const label = `${"Read documentation ".repeat(20)}\u202eevil.test`; + const html = markup(label); + const labelStart = html.indexOf(''); + + expect(html).toContain('href="https://example.com/help"'); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + expect(html).toContain("max-w-[24ch] truncate"); + expect(html).toContain(label); + expect(labelStart).toBeGreaterThan(-1); + expect(hostStart).toBeGreaterThan(labelStart); + expect(html.slice(hostStart)).toContain("(example.com)"); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx b/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx index ffc30052c9f..14f0e66aa25 100644 --- a/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx +++ b/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx @@ -12,11 +12,9 @@ import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useOptionalProject } from "~/hooks/useProject"; import { cn } from "~/utils/cn"; import { v3RunPath } from "~/utils/pathBuilder"; -import { planDiagnosisActions } from "./diagnosis-actions"; +import { planDiagnosisActions, type PlannedDiagnosisAction } from "./diagnosis-actions"; import { isRunFriendlyId } from "./run-id"; -// No markup comes from the model, so only outbound URLs need checking. - function Em({ children }: { children: React.ReactNode }) { return {children}; } @@ -129,6 +127,23 @@ function EvidenceReference({ reference }: { reference: string }) { return {reference}; } +export function DiagnosisDocsAction({ + action, +}: { + action: Extract; +}) { + return ( + + + {action.label} + + + ({action.destinationHost}) + + + ); +} + function DiagnosisActions({ actions }: { actions: NonNullable }) { const runPath = useRunPathResolver(); const planned = planDiagnosisActions(actions, { @@ -141,9 +156,7 @@ function DiagnosisActions({ actions }: { actions: NonNullable {planned.map((action, i) => action.kind === "docs" ? ( - - {action.label} - + ) : ( {action.label} diff --git a/apps/webapp/app/components/dashboard-agent/diagnosis-actions.test.ts b/apps/webapp/app/components/dashboard-agent/diagnosis-actions.test.ts index bb458eb8b47..4d9f5c309c5 100644 --- a/apps/webapp/app/components/dashboard-agent/diagnosis-actions.test.ts +++ b/apps/webapp/app/components/dashboard-agent/diagnosis-actions.test.ts @@ -27,7 +27,12 @@ describe("planDiagnosisActions", () => { it("drops only the unresolvable action, keeping the rest", () => { expect(planDiagnosisActions([viewRun, docs], withoutContext)).toEqual([ - { kind: "docs", label: "Read the docs", to: docs.target }, + { + kind: "docs", + label: "Read the docs", + to: docs.target, + destinationHost: "trigger.dev", + }, ]); }); @@ -37,10 +42,35 @@ describe("planDiagnosisActions", () => { ); }); + it("derives the displayed host from the resolved destination", () => { + expect( + planDiagnosisActions( + [{ ...docs, target: "https://example.com/help", label: "trigger.dev documentation" }], + resolve + ) + ).toEqual([ + { + kind: "docs", + label: "trigger.dev documentation", + to: "https://example.com/help", + destinationHost: "example.com", + }, + ]); + }); + it("drops a docs action with an unsafe target", () => { expect(planDiagnosisActions([{ ...docs, target: "javascript:alert(1)" }], resolve)).toEqual([]); }); + it("drops a docs action when its resolved destination has no hostname", () => { + expect( + planDiagnosisActions([docs], { + ...resolve, + docsUrl: () => "blob:https://trigger.dev/id", + }) + ).toEqual([]); + }); + it("drops an action kind it does not know", () => { expect(planDiagnosisActions([{ ...viewRun, kind: "replay_run" }], resolve)).toEqual([]); }); diff --git a/apps/webapp/app/components/dashboard-agent/diagnosis-actions.ts b/apps/webapp/app/components/dashboard-agent/diagnosis-actions.ts index 9d7a256ddd5..02100f082ed 100644 --- a/apps/webapp/app/components/dashboard-agent/diagnosis-actions.ts +++ b/apps/webapp/app/components/dashboard-agent/diagnosis-actions.ts @@ -2,11 +2,18 @@ import { isRunFriendlyId } from "./run-id"; export type DiagnosisActionInput = { kind: string; target: string; label: string }; -export type PlannedDiagnosisAction = { - kind: "view_run" | "docs"; - label: string; - to: string; -}; +export type PlannedDiagnosisAction = + | { + kind: "view_run"; + label: string; + to: string; + } + | { + kind: "docs"; + label: string; + to: string; + destinationHost: string; + }; /** * An action whose destination can't be resolved is dropped, never rendered as a @@ -29,7 +36,14 @@ export function planDiagnosisActions( } if (action.kind === "docs") { const to = resolve.docsUrl(action.target); - if (to) planned.push({ kind: "docs", label: action.label, to }); + if (!to) continue; + + try { + const destinationHost = new URL(to).hostname; + if (destinationHost) { + planned.push({ kind: "docs", label: action.label, to, destinationHost }); + } + } catch {} } } diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 64a28f53534..09f5e004706 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -1,4 +1,4 @@ -import type { Prisma, Project } from "@trigger.dev/database"; +import type { Prisma, PrismaClientOrTransaction, Project } from "@trigger.dev/database"; import { customAlphabet, nanoid } from "nanoid"; import slug from "slug"; import { $replica, prisma } from "~/db.server"; @@ -146,13 +146,20 @@ export async function createProject( return project; } -export async function findProjectBySlug(orgSlug: string, projectSlug: string, userId: string) { +export async function findProjectBySlug( + orgSlug: string, + projectSlug: string, + userId: string, + db: PrismaClientOrTransaction = $replica +) { // Find the project scoped to the organization, making sure the user belongs to that org - return await $replica.project.findFirst({ + return await db.project.findFirst({ where: { slug: projectSlug, + deletedAt: null, organization: { slug: orgSlug, + deletedAt: null, members: { some: { userId } }, }, }, @@ -166,13 +173,16 @@ export async function findProjectBySlug(orgSlug: string, projectSlug: string, us export async function findProjectWithOrgFlagsBySlug( orgSlug: string, projectSlug: string, - userId: string + userId: string, + db: PrismaClientOrTransaction = $replica ) { - return await $replica.project.findFirst({ + return await db.project.findFirst({ where: { slug: projectSlug, + deletedAt: null, organization: { slug: orgSlug, + deletedAt: null, members: { some: { userId } }, }, }, @@ -180,12 +190,18 @@ export async function findProjectWithOrgFlagsBySlug( }); } -export async function findProjectByRef(externalRef: string, userId: string) { +export async function findProjectByRef( + externalRef: string, + userId: string, + db: PrismaClientOrTransaction = $replica +) { // Find the project scoped to the organization, making sure the user belongs to that org - return await $replica.project.findFirst({ + return await db.project.findFirst({ where: { externalRef, + deletedAt: null, organization: { + deletedAt: null, members: { some: { userId } }, }, }, diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 0fe9f800c22..eb69f94698c 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -421,12 +421,15 @@ export async function findEnvironmentById(id: string): Promise { - const environment = await $replica.runtimeEnvironment.findFirst({ + const environment = await db.runtimeEnvironment.findFirst({ where: { - projectId: projectId, + projectId, slug: envSlug, + project: { deletedAt: null }, + organization: { deletedAt: null }, OR: [ { type: { diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 18df860d8d0..277a985678b 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -27,6 +27,7 @@ import { import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server"; import { boundedIn } from "@trigger.dev/database"; +import { toVercelApiError, type VercelApiError } from "~/v3/vercel/vercelApiError"; import { callVercelWithRecovery, wrapVercelCallWithRecovery, @@ -120,70 +121,7 @@ function extractCreateProjectEnvFailures(response: unknown): string[] { // Error handling // --------------------------------------------------------------------------- -export type VercelApiError = { - message: string; - authInvalid: boolean; -}; - -const VercelErrorSchema = z.union([ - z.object({ status: z.number() }), - z.object({ response: z.object({ status: z.number() }) }), - z.object({ statusCode: z.number() }), -]); - -function extractVercelErrorStatus(error: unknown): number | null { - if (error && typeof error === "object" && "status" in error) { - const parsed = VercelErrorSchema.safeParse(error); - if (parsed.success && "status" in parsed.data) { - return parsed.data.status; - } - } - - if (error && typeof error === "object" && "response" in error) { - const parsed = VercelErrorSchema.safeParse(error); - if (parsed.success && "response" in parsed.data) { - return parsed.data.response.status; - } - } - - if (error && typeof error === "object" && "statusCode" in error) { - const parsed = VercelErrorSchema.safeParse(error); - if (parsed.success && "statusCode" in parsed.data) { - return parsed.data.statusCode; - } - } - - if (typeof error === "string") { - if (error.includes("401")) return 401; - if (error.includes("403")) return 403; - } - - return null; -} - -function isVercelAuthError(error: unknown): boolean { - const status = extractVercelErrorStatus(error); - return status === 401 || status === 403; -} - -function toVercelApiError(error: unknown): VercelApiError { - if (isVercelApiErrorShape(error)) return error; - return { - message: error instanceof Error ? error.message : "Unknown error", - authInvalid: isVercelAuthError(error), - }; -} - -function isVercelApiErrorShape(error: unknown): error is VercelApiError { - return ( - error !== null && - typeof error === "object" && - "message" in error && - "authInvalid" in error && - typeof (error as VercelApiError).message === "string" && - typeof (error as VercelApiError).authInvalid === "boolean" - ); -} +export type { VercelApiError } from "~/v3/vercel/vercelApiError"; // --------------------------------------------------------------------------- // Schemas & token types // --------------------------------------------------------------------------- @@ -597,7 +535,10 @@ export class VercelIntegrationRepository { logger.warn("Failed to decrypt Vercel env var", { projectId, envVarKey: env.key, - error: result.error instanceof Error ? result.error.message : String(result.error), + error: result.error.message, + errorType: result.error.errorType, + status: result.error.status, + authInvalid: result.error.authInvalid, }); return null; } @@ -784,10 +725,10 @@ export class VercelIntegrationRepository { projectId, envId, envKey, - error: - getResult.error instanceof Error - ? getResult.error.message - : String(getResult.error), + error: getResult.error.message, + errorType: getResult.error.errorType, + status: getResult.error.status, + authInvalid: getResult.error.authInvalid, }); return null; }) @@ -2096,7 +2037,7 @@ export class VercelIntegrationRepository { ) .map(() => ({ authInvalid: false })) .orElse((error) => { - const isAuthError = isVercelAuthError(error); + const isAuthError = toVercelApiError(error).authInvalid; logger.error("Failed to uninstall Vercel integration", { installationId, error: error instanceof Error ? error.message : "Unknown error", diff --git a/apps/webapp/app/models/vercelSdkRecovery.server.ts b/apps/webapp/app/models/vercelSdkRecovery.server.ts index 70a388ee328..804edfa8f29 100644 --- a/apps/webapp/app/models/vercelSdkRecovery.server.ts +++ b/apps/webapp/app/models/vercelSdkRecovery.server.ts @@ -1,7 +1,8 @@ import { z } from "zod"; import { ResultAsync, okAsync, errAsync } from "neverthrow"; import { logger } from "~/services/logger.server"; -import type { VercelApiError } from "./vercelIntegration.server"; +import { toVercelApiError, type VercelApiError } from "~/v3/vercel/vercelApiError"; +import { vercelErrorLogMetadata } from "~/v3/vercel/vercelErrorLogMetadata"; // --------------------------------------------------------------------------- // Recovery utilities for Vercel SDK validation errors @@ -67,8 +68,7 @@ function recoverFromVercelSdkError( logger.warn("Recovered data from Vercel SDK validation error", { context: options?.context, - errorMessage: error instanceof Error ? error.message : String(error), - errorType: error?.constructor?.name, + ...vercelErrorLogMetadata(error), }); return result.data; @@ -80,17 +80,26 @@ function recoverFromVercelSdkError( * On success: returns the SDK result as-is. * On error: attempts recovery via rawValue + schema validation (validation errors only). */ +export type VercelSdkCallError = VercelApiError & { + errorType: string; + status?: number; +}; + export function callVercelWithRecovery( sdkCall: Promise, schema: z.ZodType, options?: { context?: string } -): ResultAsync { +): ResultAsync { return ResultAsync.fromPromise(sdkCall, (error) => error).orElse((error) => { const recovered = recoverFromVercelSdkError(error, schema, options); if (recovered !== undefined) { return okAsync(recovered); } - return errAsync(error); + + return errAsync({ + ...toVercelApiError(error), + ...vercelErrorLogMetadata(error), + }); }); } @@ -109,8 +118,13 @@ export function wrapVercelCallWithRecovery( ): ResultAsync { return callVercelWithRecovery(promise, schema, { context: message }).mapErr((error) => { const apiError = toError(error); - logger.error(message, { ...context, error, authInvalid: apiError.authInvalid }); - return apiError; + logger.error(message, { + ...context, + errorType: error.errorType, + ...(error.status === undefined ? {} : { status: error.status }), + authInvalid: apiError.authInvalid, + }); + return { message, authInvalid: apiError.authInvalid }; }); } diff --git a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts index 080e35d56a3..d14c18f59f7 100644 --- a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts @@ -66,7 +66,9 @@ export class EditSchedulePresenter { }, where: { slug: projectSlug, + deletedAt: null, organization: { + deletedAt: null, members: { some: { userId, @@ -76,7 +78,12 @@ export class EditSchedulePresenter { }, }); - const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId); + const environment = await findEnvironmentBySlug( + project.id, + environmentSlug, + userId, + this.#prismaClient + ); if (!environment) { throw new ServiceValidationError("No matching environment for project", 404); } @@ -120,6 +127,7 @@ export class EditSchedulePresenter { possibleTimezones: getTimezones(), schedule: await this.#getExistingSchedule( friendlyId, + project.id, possibleEnvironments, project.organizationId, project.organization.featureFlags @@ -131,7 +139,7 @@ export class EditSchedulePresenter { async #getNewSchedulePolicy(organizationId: string, featureFlags: unknown) { const [defaultWindowDurationSeconds, freeSchedulePolicy] = await Promise.all([ resolveNewScheduleDefaultWindowSeconds(this.#prismaClient, organizationId), - resolveFreeSchedulePolicyContext({ id: organizationId, featureFlags }), + resolveFreeSchedulePolicyContext(this.#prismaClient, { id: organizationId, featureFlags }), ]); return { @@ -142,6 +150,7 @@ export class EditSchedulePresenter { async #getExistingSchedule( scheduleId: string | undefined, + projectId: string, possibleEnvironments: Environment[], organizationId: string, featureFlags: unknown @@ -174,29 +183,37 @@ export class EditSchedulePresenter { }, where: { friendlyId: scheduleId, + projectId, }, }); if (!schedule) { - return undefined; + throw new Response(null, { status: 404 }); } const minimumWindowDurationSeconds = schedule.minimumWindowDurationSeconds === null ? null : resolveMinimumWindowOnUpdate( - await resolveFreeSchedulePolicyContext({ id: organizationId, featureFlags }), + await resolveFreeSchedulePolicyContext(this.#prismaClient, { + id: organizationId, + featureFlags, + }), schedule.minimumWindowDurationSeconds ).minimumWindowDurationSeconds; + const { instances, ...scheduleFields } = schedule; return { - ...schedule, + ...scheduleFields, minimumWindowDurationSeconds, cron: schedule.generatorExpression, // The form shows only the user-configured value; a blank field lets a captured default // surface through the placeholder copy rather than appearing as a typed value. window: formatScheduleWindow(schedule), - environments: schedule.instances.flatMap((instance) => { + // Whether this schedule carries a captured default, so the form copy can say "clearing + // returns to the 60-minute default" only for the new cohort, never for a grandfathered row. + hasCapturedDefaultWindow: schedule.defaultWindowDurationSeconds !== null, + environments: instances.flatMap((instance) => { const environment = possibleEnvironments.find((env) => env.id === instance.environmentId); if (!environment) { logger.error( diff --git a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts index c2015c20bb4..53a822f7806 100644 --- a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts @@ -1,5 +1,5 @@ import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse"; -import { TimeGranularity } from "~/utils/timeGranularity"; +import { isBoundedChartRange, TimeGranularity } from "~/utils/timeGranularity"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; @@ -187,6 +187,9 @@ export class ErrorGroupPresenter extends BasePresenter { versions: string[]; }> { const granularityMs = errorGroupGranularity.getTimeGranularityMs(from, to); + if (!isBoundedChartRange(from, to, granularityMs)) { + return { data: [], versions: [] }; + } const intervalExpr = msToClickHouseInterval(granularityMs); const queryBuilder = diff --git a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts index 55c675cb743..87715efc633 100644 --- a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse"; -import { TimeGranularity } from "~/utils/timeGranularity"; +import { isBoundedChartRange, TimeGranularity } from "~/utils/timeGranularity"; const errorsListGranularity = new TimeGranularity([ { max: "2h", granularity: "1m" }, @@ -358,6 +358,9 @@ export class ErrorsListPresenter extends BasePresenter { } const granularityMs = errorsListGranularity.getTimeGranularityMs(from, to); + if (!isBoundedChartRange(from, to, granularityMs)) { + return { data: {} }; + } const intervalExpr = msToClickHouseInterval(granularityMs); const queryBuilder = this.clickhouse.errors.createOccurrencesQueryBuilder(intervalExpr); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.failed/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.failed/route.tsx index 77869dc2926..200db0dc187 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.failed/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationId.subscription.v3.failed/route.tsx @@ -2,6 +2,7 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage } from "~/models/message.server"; +import { requireUserId } from "~/services/session.server"; import { v3BillingPath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ @@ -9,14 +10,17 @@ const ParamsSchema = z.object({ }); export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); const { organizationId } = ParamsSchema.parse(params); - const org = await prisma.organization.findUnique({ + const org = await prisma.organization.findFirst({ select: { slug: true, }, where: { id: organizationId, + deletedAt: null, + members: { some: { userId } }, }, }); diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts index 150fcaca3ee..e072e07a0c7 100644 --- a/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts @@ -53,7 +53,14 @@ export async function action({ request }: ActionFunctionArgs) { })) ); - logger.info("Backfilled runs", { runs }); + logger.info("Backfilled runs", { + runCount: runs.length, + runs: runs.map((run) => ({ + id: run.id, + friendlyId: run.friendlyId, + status: run.status, + })), + }); return json({ success: true, diff --git a/apps/webapp/app/routes/api.v1.auth.jwt.ts b/apps/webapp/app/routes/api.v1.auth.jwt.ts index c38cdeb14ac..715f20e6fb2 100644 --- a/apps/webapp/app/routes/api.v1.auth.jwt.ts +++ b/apps/webapp/app/routes/api.v1.auth.jwt.ts @@ -1,9 +1,12 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; -import { logger } from "~/services/logger.server"; -import { z } from "zod"; import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3"; +import { resolveJwtSigningKey, scopesWithinAbility } from "@trigger.dev/rbac"; +import parseDuration from "parse-duration"; +import { z } from "zod"; +import { authenticateBearerWithTelemetry } from "~/services/authTelemetry.server"; +import { isApiKeyInGraceWindow, presentedBearerToken } from "~/services/apiKeyGraceWindow.server"; +import { logger } from "~/services/logger.server"; const RequestBodySchema = z.object({ claims: z @@ -14,12 +17,63 @@ const RequestBodySchema = z.object({ expirationTime: z.union([z.number(), z.string()]).optional(), }); +const DEFAULT_EXPIRY = "1h"; +// A minted public JWT may be valid for at most 24 hours. A caller-controlled, +// uncapped `expirationTime` ("100y") would otherwise mint a token that no key +// rotation can revoke — public JWTs are not invalidated by rotating the +// environment's key. The cap is the compensating control for that. +const MAX_EXPIRY_SECONDS = 24 * 60 * 60; + +// A requested `expirationTime` above this (epoch seconds, ~2001) is an absolute +// timestamp; a smaller number is a relative offset in seconds. +const EXPIRY_EPOCH_THRESHOLD_SECONDS = 1_000_000_000; + +// Resolve the requested expiry to an absolute epoch-second timestamp so it can +// be capped. Returns undefined when a string can't be parsed as a duration. +function resolveRequestedExpirySeconds( + expirationTime: number | string | undefined, + nowSec: number +): number | undefined { + if (typeof expirationTime === "number") { + return expirationTime > EXPIRY_EPOCH_THRESHOLD_SECONDS + ? expirationTime + : nowSec + expirationTime; + } + const durationMs = parseDuration(expirationTime ?? DEFAULT_EXPIRY); + if (durationMs == null) { + return undefined; + } + return nowSec + Math.floor(durationMs / 1000); +} + export async function action({ request }: LoaderFunctionArgs) { try { - // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + // Authenticate through the RBAC bearer controller (not the legacy + // `authenticateApiRequest`): it exposes both the key's own ability (used + // to clamp requested scopes below) and how the key resolved (used to + // refuse grace-window keys). JWTs are not allowed to mint more JWTs. + const authenticationResult = await authenticateBearerWithTelemetry(request, { + allowJWT: false, + }); - if (!authenticationResult) { + if (!authenticationResult.ok) { + return json({ error: "Invalid or Missing API key" }, { status: authenticationResult.status }); + } + + // A revoked key still authenticates through the grace window (by design, + // for zero-downtime rotation). It must not mint new JWTs: those are signed + // with the *replacement* key (see below), so a token minted in the grace + // window keeps validating after rotation — revoking the key would not cut + // the caller off. Refuse the mint so rotation actually revokes. + // + // Detected by a direct RevokedApiKey lookup on the presenting bearer rather + // than the auth layer's `resolution.lookupPath`: the RBAC plugin path (cloud) + // resolves a grace-window key but does not surface a "root_rotated" marker, + // so keying off resolution would silently no-op there. This check is + // uniform across the plugin and OSS-fallback auth paths. + const presentedApiKey = presentedBearerToken(request); + + if (presentedApiKey && (await isApiKeyInGraceWindow(presentedApiKey))) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); } @@ -32,19 +86,49 @@ export async function action({ request }: LoaderFunctionArgs) { ); } + // A minted token must not be more powerful than the key that minted it. + // The downstream JWT auth builds its ability purely from the token's + // inline `scopes`, so caller-supplied scopes are validated against the + // key's own ability — a restricted key can't widen its grant by minting. + const requestedScopes = parsedBody.data.claims?.scopes ?? []; + const scopeCheck = scopesWithinAbility(requestedScopes, authenticationResult.ability); + if (!scopeCheck.ok) { + return json( + { + error: "Requested scopes exceed the API key's access", + code: "scopes_exceed_key_access", + deniedScopes: scopeCheck.deniedScopes, + }, + { status: 403 } + ); + } + + const nowSec = Math.floor(Date.now() / 1000); + const requestedAbsSec = resolveRequestedExpirySeconds(parsedBody.data.expirationTime, nowSec); + if (requestedAbsSec === undefined) { + return json({ error: "Invalid expiration time" }, { status: 400 }); + } + if (requestedAbsSec <= nowSec) { + return json({ error: "Expiration time must be in the future" }, { status: 400 }); + } + if (requestedAbsSec - nowSec > MAX_EXPIRY_SECONDS) { + return json({ error: "Expiration time cannot exceed 24 hours" }, { status: 400 }); + } + const claims = { sub: authenticationResult.environment.id, pub: true, - ...parsedBody.data.claims, + ...(requestedScopes.length > 0 ? { scopes: requestedScopes } : {}), }; - // Sign with the environment's current canonical key, not the raw header key, - // so JWTs minted with a revoked (grace-window) key still validate — validation - // in jwtAuth.server.ts uses environment.apiKey. + // Sign with the environment's current canonical signing key (the parent + // env's key for branches), so JWTs validate through jwtAuth.server.ts. + // Pass the absolute, capped `exp` — not the original caller string — so + // the cap above is enforced against the same value that sets the claim. const jwt = await internal_generateJWT({ - secretKey: authenticationResult.environment.apiKey, + secretKey: resolveJwtSigningKey(authenticationResult.environment), payload: claims, - expirationTime: parsedBody.data.expirationTime ?? "1h", + expirationTime: requestedAbsSec, }); return json({ token: jwt }); diff --git a/apps/webapp/app/routes/api.v1.packets.$.ts b/apps/webapp/app/routes/api.v1.packets.$.ts index ff2cc7607a7..f5358c9fb63 100644 --- a/apps/webapp/app/routes/api.v1.packets.$.ts +++ b/apps/webapp/app/routes/api.v1.packets.$.ts @@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server"; @@ -51,6 +52,15 @@ export const loader = createLoaderApiRoute( async ({ params, authentication }) => { const filename = params["*"]; + // Public tokens should use /api/v1/runs/:runId/packets/:field, which authorizes + // against the owning run. Access here is kept for clients on older SDKs. + if (authentication.type === "PUBLIC_JWT") { + logger.info("Legacy packet download with public token", { + environmentId: authentication.environment.id, + projectId: authentication.environment.projectId, + }); + } + const signed = await generatePresignedUrl( authentication.environment.project.externalRef, authentication.environment.slug, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts index c32fec3c871..8ae5af8f7c2 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts @@ -5,7 +5,6 @@ import { regenerateApiKey, RootApiKeyNotVisibleError } from "~/models/api-key.se import { authenticatedEnvironmentForAuthentication, authenticateRequest, - branchNameFromRequest, } from "~/services/apiAuth.server"; import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; import { logger } from "~/services/logger.server"; @@ -39,11 +38,15 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); } + if (request.headers.has("x-trigger-branch")) { + return json({ error: "Branch-scoped key rotation is not supported" }, { status: 400 }); + } + const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, projectRef, env, - branchNameFromRequest(request) + undefined ); // Rotating the key requires env-tier write:apiKeys — same gate the diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.packets.$field.ts b/apps/webapp/app/routes/api.v1.runs.$runId.packets.$field.ts new file mode 100644 index 00000000000..d8c7d44f0b3 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.runs.$runId.packets.$field.ts @@ -0,0 +1,80 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server"; +import { runStore } from "~/v3/runStore.server"; + +const ParamsSchema = z.object({ + runId: z.string(), + field: z.enum(["payload", "output"]), +}); + +const select = { + friendlyId: true, + taskIdentifier: true, + runTags: true, + payload: true, + payloadType: true, + output: true, + outputType: true, + batch: { select: { friendlyId: true } }, +} as const; + +function storedPacketPath( + run: { payload: string; payloadType: string; output: string | null; outputType: string }, + field: "payload" | "output" +): string | null { + const [data, dataType] = + field === "payload" ? [run.payload, run.payloadType] : [run.output, run.outputType]; + return dataType === "application/store" && data ? data : null; +} + +export const loader = createLoaderApiRoute( + { + params: ParamsSchema, + allowJWT: true, + corsStrategy: "all", + findResource: async (params, authentication) => { + const where = { + friendlyId: params.runId, + runtimeEnvironmentId: authentication.environment.id, + }; + const args = { select }; + + // The replica may not have the stored output yet for a run that just completed. + let run = await runStore.findRun(where, args, $replica); + if (!run || !storedPacketPath(run, params.field)) { + run = await runStore.findRunOnPrimary(where, args); + } + if (!run) return null; + + const packetPath = storedPacketPath(run, params.field); + return packetPath ? { ...run, packetPath } : null; + }, + authorization: { + action: "read", + resource: (run) => + anyResource([ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ...(run.batch ? [{ type: "batch", id: run.batch.friendlyId }] : []), + ]), + }, + }, + async ({ authentication, resource }) => { + const signed = await generatePresignedUrl( + authentication.environment.project.externalRef, + authentication.environment.slug, + resource.packetPath, + "GET" + ); + + if (!signed.success) { + return jsonPacketPresignFailure(signed); + } + + return json({ presignedUrl: signed.url }); + } +); diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts b/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts index 5f4c35c1556..9e9e462e6b2 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts @@ -9,6 +9,7 @@ import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server"; import { buildSyntheticSpanDetailBody } from "~/v3/mollifier/syntheticApiResponses.server"; import { runStore } from "~/v3/runStore.server"; +import { canReadRunWithAliases } from "~/utils/runReadAuthorization"; const ParamsSchema = z.object({ runId: z.string(), @@ -79,7 +80,7 @@ export const loader = createLoaderApiRoute( }, }, }, - async ({ params, resource: resolved, authentication }) => { + async ({ params, resource: resolved, authentication, ability }) => { if (resolved.source === "buffer") { // Buffered runs have exactly one valid spanId — the queued span the // mollifier gate recorded at trigger time, which becomes the run's @@ -126,6 +127,8 @@ export const loader = createLoaderApiRoute( select: { friendlyId: true, taskIdentifier: true, + runTags: true, + batchId: true, status: true, createdAt: true, }, @@ -137,6 +140,10 @@ export const loader = createLoaderApiRoute( $replica ); + const authorizedTriggeredRuns = triggeredRuns.filter((run) => + canReadRunWithAliases(ability, run) + ); + const properties = span.properties && typeof span.properties === "object" && @@ -182,8 +189,8 @@ export const loader = createLoaderApiRoute( } : undefined, triggeredRuns: - triggeredRuns.length > 0 - ? triggeredRuns.map((r) => ({ + authorizedTriggeredRuns.length > 0 + ? authorizedTriggeredRuns.map((r) => ({ runId: r.friendlyId, taskIdentifier: r.taskIdentifier, status: r.status, diff --git a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts index ba98160f22b..58296dbd9fd 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts @@ -16,7 +16,6 @@ const ParamsSchema = z.object({ const routeConfig = { params: ParamsSchema, - allowJWT: true, corsStrategy: "all" as const, findResource: async ( params: z.infer, @@ -44,6 +43,7 @@ const route = createActionApiRoute( { ...routeConfig, method: "PUT", + allowJWT: false, authorization: { action: "write", resource: (params, _, __, ___, session) => sessionResource(params.sessionId, session), diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index 3bfd3548b0a..5bd225c486a 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -63,6 +63,10 @@ const { action, loader } = createActionApiRoute( corsStrategy: "all", }, async ({ body, headers, params, authentication, ability }) => { + if (authentication.type !== "PRIVATE" && body.options?.payloadType === "application/store") { + return json({ error: "Stored payloads require a private API key" }, { status: 403 }); + } + const { "idempotency-key": idempotencyKey, "idempotency-key-ttl": idempotencyKeyTTL, diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index 2ea5ebb3b22..194955b7bef 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -41,6 +41,13 @@ const { action, loader } = createActionApiRoute( corsStrategy: "all", }, async ({ body, headers, params, authentication }) => { + if ( + authentication.type !== "PRIVATE" && + body.items.some((item) => item.options?.payloadType === "application/store") + ) { + return json({ error: "Stored payloads require a private API key" }, { status: 403 }); + } + if (!body.items.length) { return json({ error: "Batch cannot be triggered with no items" }, { status: 400 }); } diff --git a/apps/webapp/app/routes/api.v2.tasks.batch.ts b/apps/webapp/app/routes/api.v2.tasks.batch.ts index 07d476edc51..1467d99b01c 100644 --- a/apps/webapp/app/routes/api.v2.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v2.tasks.batch.ts @@ -49,6 +49,13 @@ const { action, loader } = createActionApiRoute( corsStrategy: "all", }, async ({ body, headers, params, authentication, ability }) => { + if ( + authentication.type !== "PRIVATE" && + body.items.some((item) => item.options?.payloadType === "application/store") + ) { + return json({ error: "Stored payloads require a private API key" }, { status: 403 }); + } + if (!body.items.length) { return json({ error: "Batch cannot be triggered with no items" }, { status: 400 }); } diff --git a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts index 9e2bd510256..972ed47ee73 100644 --- a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts +++ b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts @@ -95,7 +95,8 @@ export async function action({ request, params }: ActionFunctionArgs) { const itemsIterator = await authorizedBatchItemStream( streamToAsyncIterable(parsedStream), authResult.ability, - batchId + batchId, + authResult.subject.type !== "publicJWT" ); // Process the stream diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts index 529f627e5fb..182814af41b 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts @@ -5,6 +5,7 @@ import { $replica, prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; +import { sanitizeRedirectPath } from "~/utils"; import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server"; export const promoteSchema = z.object({ @@ -43,6 +44,8 @@ export const action = dashboardAction( return json(submission.reply()); } + const redirectUrl = sanitizeRedirectPath(submission.value.redirectUrl); + try { const project = await prisma.project.findFirst({ where: { @@ -58,7 +61,7 @@ export const action = dashboardAction( }); if (!project) { - return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); + return redirectWithErrorMessage(redirectUrl, request, "Project not found"); } const deployment = await prisma.workerDeployment.findFirst({ @@ -69,18 +72,14 @@ export const action = dashboardAction( }); if (!deployment) { - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - "Deployment not found" - ); + return redirectWithErrorMessage(redirectUrl, request, "Deployment not found"); } const promoteService = new ChangeCurrentDeploymentService(); await promoteService.call(deployment, "promote"); return redirectWithSuccessMessage( - submission.value.redirectUrl, + redirectUrl, request, `Promoted deployment version ${deployment.version} to current.` ); diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts index 3caed9013b3..3f4a04e4b51 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts @@ -5,6 +5,7 @@ import { $replica, prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; +import { sanitizeRedirectPath } from "~/utils"; import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server"; export const rollbackSchema = z.object({ @@ -43,6 +44,8 @@ export const action = dashboardAction( return json(submission.reply()); } + const redirectUrl = sanitizeRedirectPath(submission.value.redirectUrl); + try { const project = await prisma.project.findFirst({ where: { @@ -58,7 +61,7 @@ export const action = dashboardAction( }); if (!project) { - return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); + return redirectWithErrorMessage(redirectUrl, request, "Project not found"); } const deployment = await prisma.workerDeployment.findFirst({ @@ -69,21 +72,13 @@ export const action = dashboardAction( }); if (!deployment) { - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - "Deployment not found" - ); + return redirectWithErrorMessage(redirectUrl, request, "Deployment not found"); } const rollbackService = new ChangeCurrentDeploymentService(); await rollbackService.call(deployment, "rollback"); - return redirectWithSuccessMessage( - submission.value.redirectUrl, - request, - "Rolled back deployment" - ); + return redirectWithSuccessMessage(redirectUrl, request, "Rolled back deployment"); } catch (error) { if (error instanceof Error) { logger.error("Failed to roll back deployment", { diff --git a/apps/webapp/app/routes/resources.feedback.ts b/apps/webapp/app/routes/resources.feedback.ts index 41f0f24249d..34e3ca941d6 100644 --- a/apps/webapp/app/routes/resources.feedback.ts +++ b/apps/webapp/app/routes/resources.feedback.ts @@ -4,6 +4,7 @@ import { uiComponent } from "@team-plain/ui-components"; import { z } from "zod"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { requireUser } from "~/services/session.server"; +import { sanitizeRedirectPath } from "~/utils"; import { sendToPlain } from "~/utils/plain.server"; export const feedbackTypes = { @@ -77,6 +78,7 @@ export async function action({ request }: ActionFunctionArgs) { } const inquiry = feedbackTypes[submission.value.feedbackType as FeedbackType]; + const redirectPath = sanitizeRedirectPath(submission.value.path); try { await sendToPlain({ userId: user.id, @@ -110,7 +112,7 @@ export async function action({ request }: ActionFunctionArgs) { }); return redirectWithSuccessMessage( - submission.value.path, + redirectPath, request, "Thanks for your feedback! We'll get back to you soon." ); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index 97947f81354..65af7ba4765 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -58,6 +58,7 @@ import { import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { env } from "~/env.server"; import { GitHubSettingsPresenter } from "~/presenters/v3/GitHubSettingsPresenter.server"; import { logger } from "~/services/logger.server"; import { triggerInitialDeployment } from "~/services/platform.v3.server"; @@ -73,6 +74,7 @@ import { v3ProjectSettingsIntegrationsPath, } from "~/utils/pathBuilder"; import { type BranchTrackingConfig } from "~/v3/github"; +import { sanitizeGitHubSettingsRedirect } from "~/v3/github/githubSettingsRedirect.server"; // ============================================================================ // Types @@ -238,10 +240,13 @@ export const action = dashboardAction( const { projectId, organizationId } = membershipResultOrFail.value; const { action: actionType } = submission.value; + const redirectUrl = + sanitizeGitHubSettingsRedirect(submission.value.redirectUrl, env.APP_ORIGIN) ?? + v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, project, environment); // Handle connect-repo action if (actionType === "connect-repo") { - const { repositoryId, installationId, redirectUrl } = submission.value; + const { repositoryId, installationId } = submission.value; const resultOrFail = await projectSettingsService.connectGitHubRepo( projectId, @@ -306,8 +311,6 @@ export const action = dashboardAction( // Handle disconnect-repo action if (actionType === "disconnect-repo") { - const { redirectUrl } = submission.value; - const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId); if (resultOrFail.isOk()) { @@ -330,8 +333,7 @@ export const action = dashboardAction( // Handle update-git-settings action if (actionType === "update-git-settings") { - const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } = - submission.value; + const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value; const resultOrFail = await projectSettingsService.updateGitSettings( projectId, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title.tsx index badfae28218..2b76bd3b973 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title.tsx @@ -13,10 +13,10 @@ import { AIQueryTitleService } from "~/v3/services/aiQueryTitleService.server"; // `/resources/*` isn't covered by the global apiRateLimiter (`/api/*` only), // so this endpoint needs its own per-route limiter and length cap. -const MAX_QUERY_LENGTH = 10_000; +const QuerySchema = z.string().min(1, "Query is required").max(10_000); const RequestSchema = z.object({ - query: z.string().min(1, "Query is required").max(MAX_QUERY_LENGTH), + query: QuerySchema, queryId: z.string().optional(), }); @@ -74,10 +74,35 @@ export async function action({ request, params }: ActionFunctionArgs) { } const { query, queryId } = submission.data; + let queryToTitle = query; + + if (queryId) { + const storedQuery = await prisma.customerQuery.findFirst({ + where: { id: queryId, organizationId: project.organizationId, userId }, + select: { query: true }, + }); + + if (!storedQuery) { + return json( + { success: false as const, error: "Query not found", title: null }, + { status: 404 } + ); + } + + const authoritativeQuery = QuerySchema.safeParse(storedQuery.query); + if (!authoritativeQuery.success) { + return json( + { success: false as const, error: "Invalid request data", title: null }, + { status: 400 } + ); + } + + queryToTitle = authoritativeQuery.data; + } const service = new AIQueryTitleService(openai(env.AI_RUN_FILTER_MODEL ?? "gpt-4o-mini")); - const result = await service.generateTitle(query); + const result = await service.generateTitle(queryToTitle); if (!result.success) { return json({ success: false as const, error: result.error, title: null }, { status: 500 }); @@ -88,10 +113,17 @@ export async function action({ request, params }: ActionFunctionArgs) { // If a queryId was provided, update the CustomerQuery record with the title if (queryId) { - await prisma.customerQuery.update({ - where: { id: queryId, organizationId: project.organizationId }, + const updated = await prisma.customerQuery.updateMany({ + where: { id: queryId, organizationId: project.organizationId, userId }, data: { title }, }); + + if (updated.count === 0) { + return json( + { success: false as const, error: "Query not found", title: null }, + { status: 404 } + ); + } } return json({ success: true as const, title, error: null }); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.ai-filter.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.ai-filter.tsx index 679bd19da93..613e01f40a5 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.ai-filter.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.ai-filter.tsx @@ -154,7 +154,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const [error, result] = await tryCatch(service.call(text, environment.id)); if (error) { - return json({ success: false, error: error.message }, { status: 400 }); + return json({ success: false, error: "Unable to create filters" }, { status: 400 }); } return json(result); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 28e815701c4..38030cb84ea 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -58,6 +58,7 @@ import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organi import { logger } from "~/services/logger.server"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { checkPermissions } from "~/services/routeBuilders/permissions.server"; +import { sanitizeRedirectPath } from "~/utils"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder"; import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server"; @@ -187,6 +188,8 @@ export const action = dashboardAction( return redirectWithErrorMessage("/", request, "Invalid bulk action"); } + const failedRedirect = sanitizeRedirectPath(submission.value.failedRedirect); + // "Don't override" keeps each run's original region — drop it so it isn't // stored as a real override. if (submission.value.region === REPLAY_REGION_NO_OVERRIDE_VALUE) { @@ -222,7 +225,7 @@ export const action = dashboardAction( }); return redirectWithErrorMessage( - submission.value.failedRedirect, + failedRedirect, request, `Failed to create bulk action: ${error.message}` ); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index 404a39d9a1e..647fb704617 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -519,10 +519,9 @@ export function UpsertScheduleForm({ {environmentTitle(environment, environment.userName)} } - defaultChecked={ - schedule?.instances.find((i) => i.environmentId === environment.id) !== - undefined - } + defaultChecked={schedule?.environments.some( + (scheduledEnvironment) => scheduledEnvironment.id === environment.id + )} variant="button" /> )) diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.clicked.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.clicked.tsx index 8ddf9adbc64..a40b6246b73 100644 --- a/apps/webapp/app/routes/resources.platform-notifications.$id.clicked.tsx +++ b/apps/webapp/app/routes/resources.platform-notifications.$id.clicked.tsx @@ -11,7 +11,10 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ success: false }, { status: 400 }); } - await recordNotificationClicked({ notificationId, userId }); + const recorded = await recordNotificationClicked({ notificationId, userId }); + if (!recorded) { + return json({ success: false }, { status: 404 }); + } return json({ success: true }); } diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx index e6a475692d9..fc855ba7564 100644 --- a/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx +++ b/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx @@ -11,7 +11,10 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ success: false }, { status: 400 }); } - await dismissNotification({ notificationId, userId }); + const recorded = await dismissNotification({ notificationId, userId }); + if (!recorded) { + return json({ success: false }, { status: 404 }); + } return json({ success: true }); } diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx index 652d4ee99c0..557e53c8cd7 100644 --- a/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx +++ b/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx @@ -11,7 +11,10 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ success: false }, { status: 400 }); } - await recordNotificationSeen({ notificationId, userId }); + const recorded = await recordNotificationSeen({ notificationId, userId }); + if (!recorded) { + return json({ success: false }, { status: 404 }); + } return json({ success: true }); } diff --git a/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts b/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts index b0f820fccc7..92a5003d749 100644 --- a/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts +++ b/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts @@ -6,6 +6,7 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; +import { sanitizeRedirectPath } from "~/utils"; export const closeSessionSchema = z.object({ redirectUrl: z.string(), @@ -28,7 +29,8 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission.reply()); } - const { redirectUrl, environmentId, reason } = submission.value; + const { environmentId, reason } = submission.value; + const redirectUrl = sanitizeRedirectPath(submission.value.redirectUrl); const trimmedReason = reason?.trim(); const closedReason = trimmedReason && trimmedReason.length > 0 ? trimmedReason : "closed-from-dashboard"; diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts index 7610f2c099c..698b73e4388 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts @@ -9,6 +9,7 @@ import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server"; import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server"; import { runStore } from "~/v3/runStore.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; +import { sanitizeRedirectPath } from "~/utils"; export const cancelSchema = z.object({ redirectUrl: z.string(), @@ -75,6 +76,8 @@ export const action = dashboardAction( return json(submission.reply()); } + const redirectUrl = sanitizeRedirectPath(submission.value.redirectUrl); + try { // Keyed by friendlyId only so the store routes to the owning run-ops DB. // The project-scope + membership auth is a control-plane concern resolved @@ -116,7 +119,7 @@ export const action = dashboardAction( if (taskRun && authorized) { const cancelRunService = new CancelTaskRunService(); await cancelRunService.call(taskRun); - return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`); + return redirectWithSuccessMessage(redirectUrl, request, `Canceled run`); } // PG miss — try the mollifier buffer (customer cancelled a buffered run @@ -146,7 +149,7 @@ export const action = dashboardAction( cancelReason: "Canceled by user", }); if (result === "applied_to_snapshot") { - return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`); + return redirectWithSuccessMessage(redirectUrl, request, `Canceled run`); } // "not_found" or "busy" — both indicate the drainer raced us between // the getEntry check above and mutateSnapshot. On "not_found" the @@ -155,7 +158,7 @@ export const action = dashboardAction( // retry — by then the PG row exists and the regular cancel path at // the top of this action takes over. return redirectWithErrorMessage( - submission.value.redirectUrl, + redirectUrl, request, "Run is materialising — retry in a moment" ); @@ -169,14 +172,14 @@ export const action = dashboardAction( }, }); return redirectWithErrorMessage( - submission.value.redirectUrl, + redirectUrl, request, `Failed to cancel run, ${error.message}` ); } else { logger.error("Failed to cancel run", { error }); return redirectWithErrorMessage( - submission.value.redirectUrl, + redirectUrl, request, `Failed to cancel run, ${JSON.stringify(error)}` ); diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index 73e45b94d5d..c409575a146 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -10,6 +10,7 @@ import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; +import { sanitizeRedirectPath } from "~/utils"; import { sortEnvironments } from "~/utils/environmentSort"; import { v3RunSpanPath } from "~/utils/pathBuilder"; import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server"; @@ -325,6 +326,8 @@ export const action = dashboardAction( return json(submission.reply()); } + const failedRedirect = sanitizeRedirectPath(submission.value.failedRedirect); + try { // Run-ops read keyed by friendlyId only; membership auth is re-checked on the // control plane below, keyed off the resolved run's projectId. @@ -372,11 +375,7 @@ export const action = dashboardAction( select: { id: true }, }); if (!member) { - return redirectWithErrorMessage( - submission.value.failedRedirect, - request, - "Run not found" - ); + return redirectWithErrorMessage(failedRedirect, request, "Run not found"); } const synthetic = await findRunByIdWithMollifierFallback({ runId: runParam, @@ -401,7 +400,7 @@ export const action = dashboardAction( } if (!taskRun) { - return redirectWithErrorMessage(submission.value.failedRedirect, request, "Run not found"); + return redirectWithErrorMessage(failedRedirect, request, "Run not found"); } // A replay can target a different environment, but only within the source @@ -421,11 +420,7 @@ export const action = dashboardAction( select: { id: true }, }); if (!overrideEnvironment) { - return redirectWithErrorMessage( - submission.value.failedRedirect, - request, - "Environment not found" - ); + return redirectWithErrorMessage(failedRedirect, request, "Environment not found"); } } @@ -451,11 +446,7 @@ export const action = dashboardAction( }); if (!newRun) { - return redirectWithErrorMessage( - submission.value.failedRedirect, - request, - "Failed to replay run" - ); + return redirectWithErrorMessage(failedRedirect, request, "Failed to replay run"); } const runPath = v3RunSpanPath( @@ -486,15 +477,11 @@ export const action = dashboardAction( stack: error.stack, }, }); - return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message); + return redirectWithErrorMessage(failedRedirect, request, error.message); } logger.error("Failed to replay run", { error }); - return redirectWithErrorMessage( - submission.value.failedRedirect, - request, - JSON.stringify(error) - ); + return redirectWithErrorMessage(failedRedirect, request, JSON.stringify(error)); } } ); diff --git a/apps/webapp/app/routes/tests.sse.stream.ts b/apps/webapp/app/routes/tests.sse.stream.ts deleted file mode 100644 index c4fc62c89ab..00000000000 --- a/apps/webapp/app/routes/tests.sse.stream.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { z } from "zod"; -import { logger } from "~/services/logger.server"; -import { sse } from "~/utils/sse.server"; - -export async function loader({ request }: LoaderFunctionArgs) { - const url = new URL(request.url); - const searchParams = Object.fromEntries(url.searchParams.entries()); - - const options = z - .object({ - minDelay: z.coerce.number().int(), - maxDelay: z.coerce.number().int(), - undefinedProbability: z.coerce.number().min(0).max(1).default(0.1), - }) - .parse(searchParams); - - logger.debug("Test SSE stream", { options }); - - let lastSignals = calculateChangeSignals(Date.now()); - - return sse({ - request, - run: async (send, stop) => { - const result = await dateForUpdates(options); - - if (!result) { - return stop(); - } - - const newSignals = calculateChangeSignals(result); - - if (lastSignals.ts !== newSignals.ts) { - send({ data: JSON.stringify(newSignals) }); - } - - lastSignals = newSignals; - }, - }); -} - -async function dateForUpdates(opts: { - minDelay: number; - maxDelay: number; - undefinedProbability: number; -}): Promise { - // Randomly await between minDelay and maxDelay - await new Promise((resolve) => { - setTimeout(resolve, Math.random() * (opts.maxDelay - opts.minDelay) + opts.minDelay); - }); - - // There should be about a x% chance that this returns undefined - if (Math.random() < opts.undefinedProbability) { - logger.debug("Test SSE dataForUpdates returning undefined"); - - return undefined; - } - - // Randomly return true or false - return Date.now(); -} - -function calculateChangeSignals(ts: number) { - return { - ts, - }; -} diff --git a/apps/webapp/app/routes/tests.sse.tsx b/apps/webapp/app/routes/tests.sse.tsx deleted file mode 100644 index 65dafd3b7ae..00000000000 --- a/apps/webapp/app/routes/tests.sse.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useLoaderData } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { useEventSource } from "~/hooks/useEventSource"; -import { z } from "zod"; - -export async function loader({ request }: LoaderFunctionArgs) { - const url = new URL(request.url); - const params = Object.fromEntries(url.searchParams.entries()); - - const config = z - .object({ - minDelay: z.coerce.number().int().min(0).max(10000).default(1000), - maxDelay: z.coerce.number().int().min(0).max(10000).default(2000), - undefinedProbability: z.coerce.number().min(0).max(1).default(0.1), - }) - .parse(params); - - return config; -} - -export default function SSETest() { - const { minDelay, maxDelay, undefinedProbability } = useLoaderData(); - - const events = useEventSource( - `/tests/sse/stream?minDelay=${minDelay}&maxDelay=${maxDelay}&undefinedProbability=${undefinedProbability}`, - { - event: "message", - } - ); - - return ( -
-

SSE Test

-

{events ?? "No events"}

-
- ); -} diff --git a/apps/webapp/app/routes/vercel.configure.tsx b/apps/webapp/app/routes/vercel.configure.tsx index a9b4f4a151f..775e2a0df92 100644 --- a/apps/webapp/app/routes/vercel.configure.tsx +++ b/apps/webapp/app/routes/vercel.configure.tsx @@ -13,7 +13,7 @@ const SearchParamsSchema = z.object({ * Endpoint to handle Vercel integration configuration request coming from marketplace */ export const loader = async ({ request }: LoaderFunctionArgs) => { - await requireUserId(request); + const userId = await requireUserId(request); const url = new URL(request.url); const searchParams = Object.fromEntries(url.searchParams); @@ -28,8 +28,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { path: ["installationId"], equals: configurationId, }, + organization: { + members: { + some: { userId }, + }, + }, }, - include: { + select: { organization: { select: { slug: true, diff --git a/apps/webapp/app/runEngine/validators/triggerTaskValidator.ts b/apps/webapp/app/runEngine/validators/triggerTaskValidator.ts index 97e98d1aeb0..8fce881ac12 100644 --- a/apps/webapp/app/runEngine/validators/triggerTaskValidator.ts +++ b/apps/webapp/app/runEngine/validators/triggerTaskValidator.ts @@ -76,7 +76,8 @@ export class DefaultTriggerTaskValidator implements TriggerTaskValidator { // Check if the parent run is in a final state if (isFinalRunStatus(parentRun.status)) { logger.debug("Parent run is in a terminal state", { - parentRun, + parentRunId: parentRun.friendlyId, + status: parentRun.status, }); return { diff --git a/apps/webapp/app/services/apiKeyGraceWindow.server.ts b/apps/webapp/app/services/apiKeyGraceWindow.server.ts new file mode 100644 index 00000000000..9da6cc4cbc1 --- /dev/null +++ b/apps/webapp/app/services/apiKeyGraceWindow.server.ts @@ -0,0 +1,17 @@ +import { $replica } from "~/db.server"; + +export function presentedBearerToken(request: Request): string | undefined { + return request.headers + .get("Authorization") + ?.replace(/^Bearer /i, "") + .trim(); +} + +export async function isApiKeyInGraceWindow(apiKey: string): Promise { + const revokedKey = await $replica.revokedApiKey.findFirst({ + where: { apiKey, expiresAt: { gt: new Date() } }, + select: { id: true }, + }); + + return revokedKey !== null; +} diff --git a/apps/webapp/app/services/apiKeyTelemetry.server.ts b/apps/webapp/app/services/apiKeyTelemetry.server.ts index 3d4b80fe5a1..188e654cde4 100644 --- a/apps/webapp/app/services/apiKeyTelemetry.server.ts +++ b/apps/webapp/app/services/apiKeyTelemetry.server.ts @@ -14,6 +14,7 @@ export type PublicTokenMintResult = "success" | "rejected" | "error"; export type PublicTokenMintReason = | "none" | "invalid_body" + | "revoked_key" | "scope_not_allowed" | "invalid_expiration" | "expiration_not_future" diff --git a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts index 3d5871d9946..1b214c6b3f4 100644 --- a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts +++ b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts @@ -6,6 +6,7 @@ import type { Request as ExpressRequest, Response as ExpressResponse, NextFuncti import { createHash } from "node:crypto"; import { z } from "zod"; import type { RedisWithClusterOptions } from "~/redis.server"; +import { getRouterPath } from "~/utils/sanitizeHttpUrl"; import { logger } from "./logger.server"; import type { Duration, Limiter } from "./rateLimiter.server"; import { createRedisRateLimitClient, RateLimiter } from "./rateLimiter.server"; @@ -229,9 +230,10 @@ export function authorizationRateLimitMiddleware({ return next(); } - //first check if any of the pathMatchers match the request path - const path = req.path; + // Use the same normalized pathname that the application router receives. + const path = getRouterPath(req); if ( + !path || !pathMatchers.some((matcher) => matcher instanceof RegExp ? matcher.test(path) : path === matcher ) diff --git a/apps/webapp/app/services/engineRateLimit.server.ts b/apps/webapp/app/services/engineRateLimit.server.ts index 316b6c66c2a..a7c0fbad13d 100644 --- a/apps/webapp/app/services/engineRateLimit.server.ts +++ b/apps/webapp/app/services/engineRateLimit.server.ts @@ -23,9 +23,8 @@ export const engineRateLimiter = authorizationRateLimitMiddleware({ stale: 60_000 * 20, // Date is stale after 20 minutes maxItems: 1000, }, - pathMatchers: [/^\/engine/], - // Regex allow any path starting with /engine/v1/worker-actions/ - pathWhiteList: [/^\/engine\/v1\/worker-actions\/.*/], + pathMatchers: [/^\/engine(?:\/|$)/], + pathWhiteList: [/^\/engine\/v1\/worker-actions(?:\/|$)/], log: { rejections: env.RUN_ENGINE_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1", requests: env.RUN_ENGINE_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1", diff --git a/apps/webapp/app/services/platformNotifications.server.ts b/apps/webapp/app/services/platformNotifications.server.ts index 6186f168c50..60a842415e4 100644 --- a/apps/webapp/app/services/platformNotifications.server.ts +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -225,18 +225,50 @@ function compareNotifications( // --- Write: upsert interaction --- -async function upsertInteraction({ - notificationId, - userId, - onUpdate, - onCreate, -}: { - notificationId: string; - userId: string; - onUpdate: Record; - onCreate: Record; -}) { - await prisma.platformNotificationInteraction.upsert({ +async function upsertInteraction( + { + notificationId, + userId, + onUpdate, + onCreate, + }: { + notificationId: string; + userId: string; + onUpdate: Record; + onCreate: Record; + }, + db: PrismaClientOrTransaction +) { + const notification = await db.platformNotification.findFirst({ + where: { + id: notificationId, + surface: "WEBAPP", + isDraft: false, + startsAt: { lte: new Date() }, + OR: [ + { scope: "GLOBAL" }, + { scope: "USER", userId }, + { + scope: "ORGANIZATION", + organization: { deletedAt: null, members: { some: { userId } } }, + }, + { + scope: "PROJECT", + project: { + deletedAt: null, + organization: { deletedAt: null, members: { some: { userId } } }, + }, + }, + ], + }, + select: { id: true }, + }); + + if (!notification) { + return false; + } + + await db.platformNotificationInteraction.upsert({ where: { notificationId_userId: { notificationId, userId } }, update: onUpdate, create: { @@ -247,53 +279,73 @@ async function upsertInteraction({ ...onCreate, }, }); + + return true; } -export async function recordNotificationSeen({ - notificationId, - userId, -}: { - notificationId: string; - userId: string; -}) { - return upsertInteraction({ +export async function recordNotificationSeen( + { notificationId, userId, - onUpdate: { showCount: { increment: 1 } }, - onCreate: {}, - }); + }: { + notificationId: string; + userId: string; + }, + db: PrismaClientOrTransaction = prisma +) { + return upsertInteraction( + { + notificationId, + userId, + onUpdate: { showCount: { increment: 1 } }, + onCreate: {}, + }, + db + ); } -export async function dismissNotification({ - notificationId, - userId, -}: { - notificationId: string; - userId: string; -}) { - const now = new Date(); - return upsertInteraction({ +export async function dismissNotification( + { notificationId, userId, - onUpdate: { webappDismissedAt: now }, - onCreate: { webappDismissedAt: now }, - }); + }: { + notificationId: string; + userId: string; + }, + db: PrismaClientOrTransaction = prisma +) { + const now = new Date(); + return upsertInteraction( + { + notificationId, + userId, + onUpdate: { webappDismissedAt: now }, + onCreate: { webappDismissedAt: now }, + }, + db + ); } -export async function recordNotificationClicked({ - notificationId, - userId, -}: { - notificationId: string; - userId: string; -}) { - const now = new Date(); - return upsertInteraction({ +export async function recordNotificationClicked( + { notificationId, userId, - onUpdate: { webappClickedAt: now }, - onCreate: { webappClickedAt: now }, - }); + }: { + notificationId: string; + userId: string; + }, + db: PrismaClientOrTransaction = prisma +) { + const now = new Date(); + return upsertInteraction( + { + notificationId, + userId, + onUpdate: { webappClickedAt: now }, + onCreate: { webappClickedAt: now }, + }, + db + ); } // --- Membership verification --- diff --git a/apps/webapp/app/services/publicTokens.server.ts b/apps/webapp/app/services/publicTokens.server.ts index 8d471a5b7c7..f6545185991 100644 --- a/apps/webapp/app/services/publicTokens.server.ts +++ b/apps/webapp/app/services/publicTokens.server.ts @@ -3,6 +3,7 @@ import type { RoleBaseAccessController } from "@trigger.dev/rbac"; import { resolveJwtSigningKey, scopesWithinAbility } from "@trigger.dev/rbac"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; +import { isApiKeyInGraceWindow, presentedBearerToken } from "~/services/apiKeyGraceWindow.server"; import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server"; import { rbac } from "~/services/rbac.server"; @@ -55,7 +56,8 @@ function expirationTimestamp(expirationTime: string | number, now: number): numb export async function handlePublicTokenRequest( request: Request, controller: Pick = rbac, - telemetryRecorder: ApiKeyTelemetry = apiKeyTelemetry + telemetryRecorder: ApiKeyTelemetry = apiKeyTelemetry, + graceWindowLookup: (apiKey: string) => Promise = isApiKeyInGraceWindow ) { // Public JWTs are intentionally not enabled here. Only API keys may mint tokens. const authResult = await controller.authenticateBearer(request); @@ -63,6 +65,16 @@ export async function handlePublicTokenRequest( return json({ error: authResult.error }, { status: authResult.status }); } + // Additional keys revoke immediately and are not stored in RevokedApiKey. Root keys use a + // rotation grace window, but must not use it to mint a token signed by the replacement key. + if (authResult.subject.type === "user") { + const presentedApiKey = presentedBearerToken(request); + if (presentedApiKey && (await graceWindowLookup(presentedApiKey))) { + telemetryRecorder.recordPublicTokenMint("rejected", "revoked_key"); + return json({ error: "Invalid API key" }, { status: 401 }); + } + } + let body: unknown; try { body = await request.json(); diff --git a/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts b/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts index f3ddc65b542..3444125f317 100644 --- a/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/redisRealtimeStreams.server.ts @@ -22,6 +22,17 @@ type StreamChunk = | { type: "data"; redisId: string; data: string } | { type: "legacy-data"; redisId: string; data: string }; +function formatSseDataEvent(redisId: string, data: string): string { + const withoutDelimiterCarriageReturn = data.endsWith("\r") ? data.slice(0, -1) : data; + const normalized = withoutDelimiterCarriageReturn.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const dataFields = normalized + .split("\n") + .map((part) => `data: ${part}`) + .join("\n"); + + return `id: ${redisId}\n${dataFields}\n\n`; +} + // Class implementing both interfaces export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { private logger: Logger; @@ -277,8 +288,7 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder { if (chunk.type === "ping") { controller.enqueue(`: ping\n\n`); } else if ((chunk.type === "data" || chunk.type === "legacy-data") && chunk.line) { - // Use Redis stream ID as SSE event ID - controller.enqueue(`id: ${chunk.redisId}\ndata: ${chunk.line}\n\n`); + controller.enqueue(formatSseDataEvent(chunk.redisId, chunk.line)); } }, }) diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.test.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.test.ts new file mode 100644 index 00000000000..eae9ab79586 --- /dev/null +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.test.ts @@ -0,0 +1,186 @@ +import { createCache, createMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { S2RealtimeStreams } from "./s2realtimeStreams.server"; + +const BASIN = "test-basin"; +const STREAM_PREFIX = "org/org_123/env/dev/env_123"; + +type IssueAccessTokenRequest = { + id: string; + scope: { + basins: { exact: string }; + ops: string[]; + streams: { exact?: string; prefix?: string }; + }; + expires_at: string; + auto_prefix_streams: boolean; +}; + +let accountServer: Server; +let accountUrl: string; +let issuedRequests: IssueAccessTokenRequest[] = []; + +beforeAll(async () => { + accountServer = createServer(async (request, response) => { + if (request.method !== "POST" || request.url !== "/v1/access-tokens") { + response.writeHead(404).end(); + return; + } + + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + issuedRequests.push(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + + response.writeHead(201, { "content-type": "application/json" }); + response.end(JSON.stringify({ access_token: `issued-token-${issuedRequests.length}` })); + }); + + await new Promise((resolve) => accountServer.listen(0, "127.0.0.1", resolve)); + const address = accountServer.address() as AddressInfo; + accountUrl = `http://127.0.0.1:${address.port}/v1`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + accountServer.close((error) => (error ? reject(error) : resolve())); + }); +}); + +beforeEach(() => { + issuedRequests = []; +}); + +function createAccessTokenCache() { + const context = new DefaultStatefulContext(); + return createCache({ + accessToken: new Namespace(context, { + stores: [createMemoryStore(100, 0)], + fresh: 60_000, + stale: 120_000, + }), + }); +} + +function createStreams(options?: { + cache?: ReturnType; + skipAccessTokens?: boolean; + accessToken?: string; +}) { + return new S2RealtimeStreams({ + basin: BASIN, + accessToken: options?.accessToken ?? "account-token", + streamPrefix: STREAM_PREFIX, + accountUrl, + basinUrl: `${accountUrl}/basins/{basin}`, + cache: options?.cache, + skipAccessTokens: options?.skipAccessTokens, + }); +} + +describe("S2RealtimeStreams access-token scopes", () => { + it("scopes the ordinary session input initializer to its exact full stream name", async () => { + const streams = createStreams(); + + const result = await streams.initializeSessionStream("session_123", "in"); + + const streamName = `${STREAM_PREFIX}/sessions/session_123/in`; + expect(result.responseHeaders).toMatchObject({ + "X-S2-Access-Token": "issued-token-1", + "X-S2-Stream-Name": streamName, + "X-S2-Basin": BASIN, + }); + expect(issuedRequests).toHaveLength(1); + expect(issuedRequests[0]).toMatchObject({ + scope: { + basins: { exact: BASIN }, + ops: ["append", "create-stream"], + streams: { exact: streamName }, + }, + auto_prefix_streams: false, + }); + expect(issuedRequests[0]!.scope.streams).not.toHaveProperty("prefix"); + }); + + it("scopes the named session input initializer to only that channel", async () => { + const streams = createStreams(); + + const result = await streams.initializeSessionStream("chat-room", "in", "steering"); + + const streamName = `${STREAM_PREFIX}/sessions/chat-room/channels/steering/in`; + expect(result.responseHeaders?.["X-S2-Stream-Name"]).toBe(streamName); + expect(issuedRequests[0]).toMatchObject({ + scope: { + ops: ["append", "create-stream"], + streams: { exact: streamName }, + }, + auto_prefix_streams: false, + }); + }); + + it("keeps trim authority on an exact private session output stream", async () => { + const streams = createStreams(); + + const result = await streams.initializeSessionStream("session_123", "out", "updates"); + + const streamName = `${STREAM_PREFIX}/sessions/session_123/channels/updates/out`; + expect(result.responseHeaders?.["X-S2-Stream-Name"]).toBe(streamName); + expect(issuedRequests[0]).toMatchObject({ + scope: { + ops: ["append", "create-stream", "trim"], + streams: { exact: streamName }, + }, + auto_prefix_streams: false, + }); + }); + + it("keeps the relative, auto-prefixed contract for run stream writers", async () => { + const streams = createStreams(); + + const result = await streams.initializeStream("run_123", "progress"); + + expect(result.responseHeaders?.["X-S2-Stream-Name"]).toBe("/runs/run_123/progress"); + expect(issuedRequests[0]).toMatchObject({ + scope: { + ops: ["append", "create-stream"], + streams: { prefix: STREAM_PREFIX }, + }, + auto_prefix_streams: true, + }); + }); + + it("caches each exact scope separately and ignores the previous broad cache namespace", async () => { + const cache = createAccessTokenCache(); + const previousCacheKey = `${BASIN}:${STREAM_PREFIX}:append,create-stream,trim`; + await cache.accessToken.set(previousCacheKey, "broad-token"); + const streams = createStreams({ cache }); + + const first = await streams.initializeSessionStream("session_123", "in"); + const repeated = await streams.initializeSessionStream("session_123", "in"); + const different = await streams.initializeSessionStream("session_456", "in"); + + expect(first.responseHeaders?.["X-S2-Access-Token"]).toBe("issued-token-1"); + expect(repeated.responseHeaders?.["X-S2-Access-Token"]).toBe("issued-token-1"); + expect(different.responseHeaders?.["X-S2-Access-Token"]).toBe("issued-token-2"); + expect(issuedRequests).toHaveLength(2); + }); + + it("returns full stream names when token issuance is disabled", async () => { + const streams = createStreams({ skipAccessTokens: true, accessToken: "" }); + + const ordinary = await streams.initializeSessionStream("session_123", "in"); + const named = await streams.initializeSessionStream("session_123", "in", "steering"); + + expect(ordinary.responseHeaders).toMatchObject({ + "X-S2-Access-Token": "s2-skip-access-tokens", + "X-S2-Stream-Name": `${STREAM_PREFIX}/sessions/session_123/in`, + }); + expect(named.responseHeaders?.["X-S2-Stream-Name"]).toBe( + `${STREAM_PREFIX}/sessions/session_123/channels/steering/in` + ); + expect(issuedRequests).toHaveLength(0); + }); +}); diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts index 86acc15e80b..869c09a17b0 100644 --- a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts @@ -68,15 +68,19 @@ export type S2RealtimeStreamsOptions = { }>; }; -// Ops the issued S2 access token is scoped to. `trim` is a distinct op -// from `append` even though trim records are appended like any other — -// without it, `AppendRecord.trim()` 403s with "Operation not permitted". -// `chat.agent`'s per-turn trim chain depends on it. -// -// The fingerprint folds the ops list into the cache key, so any future -// scope change auto-invalidates pre-deploy cached tokens. -const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const; -const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(","); +const S2_APPEND_TOKEN_OPS = ["append", "create-stream"] as const; +const S2_SESSION_OUT_TOKEN_OPS = [...S2_APPEND_TOKEN_OPS, "trim"] as const; +const S2_TOKEN_CACHE_VERSION = "v2"; + +type S2TokenOperation = (typeof S2_SESSION_OUT_TOKEN_OPS)[number]; +type S2TokenStreamScope = + | { kind: "exact"; value: string; autoPrefixStreams: false } + | { kind: "prefix"; value: string; autoPrefixStreams: true }; + +type S2TokenScope = { + operations: readonly S2TokenOperation[]; + stream: S2TokenStreamScope; +}; /** * Placeholder handed back as the S2 access token when `skipAccessTokens` is set @@ -164,10 +168,12 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { runId: string, streamId: string ): Promise<{ responseHeaders?: Record }> { - return this.#initializeStreamByName( - this.toStreamName(runId, streamId), - `/runs/${runId}/${streamId}` - ); + const prefixedName = this.toStreamName(runId, streamId); + // Run writers use the relative-name contract shipped by older SDKs. + return this.#initializeStreamByName(prefixedName, `/runs/${runId}/${streamId}`, { + operations: S2_APPEND_TOKEN_OPS, + stream: { kind: "prefix", value: this.streamPrefix, autoPrefixStreams: true }, + }); } /** @@ -179,24 +185,27 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { io: "out" | "in", channel?: string ): Promise<{ responseHeaders?: Record }> { - return this.#initializeStreamByName( - this.toSessionStreamName(friendlyId, io, channel), - this.#sessionStreamRelativeName(friendlyId, io, channel) - ); + const streamName = this.toSessionStreamName(friendlyId, io, channel); + // Exact scopes cannot use S2 auto-prefixing, so clients receive the full name. + return this.#initializeStreamByName(streamName, streamName, { + operations: io === "out" ? S2_SESSION_OUT_TOKEN_OPS : S2_APPEND_TOKEN_OPS, + stream: { kind: "exact", value: streamName, autoPrefixStreams: false }, + }); } async #initializeStreamByName( prefixedName: string, - relativeName: string + clientStreamName: string, + tokenScope: S2TokenScope ): Promise<{ responseHeaders?: Record }> { const accessToken = this.skipAccessTokens ? this.token || SKIP_ACCESS_TOKENS_SENTINEL - : await this.getS2AccessToken(randomUUID()); + : await this.getS2AccessToken(randomUUID(), tokenScope); return { responseHeaders: { "X-S2-Access-Token": accessToken, - "X-S2-Stream-Name": this.skipAccessTokens ? prefixedName : relativeName, + "X-S2-Stream-Name": this.skipAccessTokens ? prefixedName : clientStreamName, "X-S2-Basin": this.basin, "X-S2-Flush-Interval-Ms": this.flushIntervalMs.toString(), "X-S2-Max-Retries": this.maxRetries.toString(), @@ -231,8 +240,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { } async #appendPartByName(part: string, partId: string, s2Stream: string): Promise { - this.logger.debug(`S2 appending to stream`, { part, stream: s2Stream }); - const recordBody = JSON.stringify({ data: part, id: partId }); const meteredBytes = Buffer.byteLength(recordBody, "utf8") + S2_RECORD_BASE_OVERHEAD_BYTES; if (meteredBytes > S2_MAX_METERED_BYTES) { @@ -243,8 +250,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { records: [{ body: recordBody }], }); - this.logger.debug(`S2 append result`, { result }); - return result.start.seq_num; } @@ -305,8 +310,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { if (!res.ok) { if (res.status === 404) return names; - const text = await res.text().catch(() => ""); - throw new Error(`S2 listStreams failed: ${res.status} ${res.statusText} ${text}`); + await cancelResponseBody(res); + throw new Error(`S2 listStreams failed: ${res.status} ${res.statusText}`); } const body = (await res.json()) as { @@ -351,8 +356,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { if (res.status === 404) { return []; } - const text = await res.text().catch(() => ""); - throw new Error(`S2 readRecords failed: ${res.status} ${res.statusText} ${text}`); + await cancelResponseBody(res); + throw new Error(`S2 readRecords failed: ${res.status} ${res.statusText}`); } // Parse the SSE response body to extract records @@ -527,8 +532,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { "S2-Basin": this.basin, }, }); - } catch (err) { - this.logger.warn("S2 peek last record: fetch failed", { err, stream: s2Stream }); + } catch { + this.logger.warn("S2 peek last record: fetch failed"); return false; } @@ -536,12 +541,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { // 404: stream has never been written to. 416: range not // satisfiable (empty stream). Both mean "nothing to peek." if (res.status === 404 || res.status === 416) return false; - const text = await res.text().catch(() => ""); + await cancelResponseBody(res); this.logger.warn("S2 peek last record failed", { status: res.status, statusText: res.statusText, - text, - stream: s2Stream, }); return false; } @@ -562,8 +565,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { }>; }; records = json.records ?? []; - } catch (err) { - this.logger.warn("S2 peek last record: parse failed", { err, stream: s2Stream }); + } catch { + this.logger.warn("S2 peek last record: parse failed"); return false; } @@ -591,12 +594,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { const tailFromLatest = startSeq == null && options?.startFrom === "latest"; - this.logger.info(`S2 streaming records from stream`, { - stream: s2Stream, - startSeq, - tailFromLatest, - }); - // Request SSE stream from S2 and return it directly const s2Response = await this.s2StreamRecords(s2Stream, { ...(tailFromLatest @@ -650,8 +647,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { if (res.ok) { return (await res.json()) as S2AppendAck; } - const text = await res.text().catch(() => ""); - const httpError = new Error(`S2 append failed: ${res.status} ${res.statusText} ${text}`); + await cancelResponseBody(res); + const httpError = new Error(`S2 append failed: ${res.status} ${res.statusText}`); if (res.status >= 400 && res.status < 500) { // 4xx — caller-side problem (auth, malformed body, closed stream). // Retrying won't help. @@ -665,7 +662,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { const diagnostics = describeFetchError(lastError); if (isLastAttempt) { this.logger.error("S2 append failed after retries", { - stream, attempts: maxAttempts, ...diagnostics, }); @@ -673,7 +669,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { } this.logger.warn("S2 append transient failure, retrying", { - stream, attempt: attempt + 1, nextDelayMs: backoffsMs[attempt], ...diagnostics, @@ -684,18 +679,21 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { throw lastError instanceof Error ? lastError : new Error(String(lastError)); } - private async getS2AccessToken(id: string): Promise { + private async getS2AccessToken(id: string, tokenScope: S2TokenScope): Promise { if (!this.cache) { - return this.s2IssueAccessToken(id); + return this.s2IssueAccessToken(id, tokenScope); } - // Cache key includes basin so per-org basins never collide on - // cached tokens, and the ops fingerprint so a scope change in code - // (e.g. adding `trim` in #3644) auto-invalidates pre-deploy entries - // instead of returning stale tokens for up to 24h. - const cacheKey = `${this.basin}:${this.streamPrefix}:${S2_TOKEN_OPS_FINGERPRINT}`; + const cacheKey = JSON.stringify([ + S2_TOKEN_CACHE_VERSION, + this.basin, + tokenScope.stream.kind, + tokenScope.stream.value, + [...tokenScope.operations].sort(), + tokenScope.stream.autoPrefixStreams, + ]); const result = await this.cache.accessToken.swr(cacheKey, async () => { - return this.s2IssueAccessToken(id); + return this.s2IssueAccessToken(id, tokenScope); }); if (!result.val) { @@ -705,7 +703,12 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { return result.val; } - private async s2IssueAccessToken(id: string): Promise { + private async s2IssueAccessToken(id: string, tokenScope: S2TokenScope): Promise { + const streams = + tokenScope.stream.kind === "exact" + ? { exact: tokenScope.stream.value } + : { prefix: tokenScope.stream.value }; + // POST /v1/access-tokens const res = await fetch(`${this.accountUrl}/access-tokens`, { method: "POST", @@ -719,19 +722,17 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { basins: { exact: this.basin, }, - ops: [...S2_TOKEN_OPS], - streams: { - prefix: this.streamPrefix, - }, + ops: [...tokenScope.operations], + streams, }, expires_at: new Date(Date.now() + this.accessTokenExpirationInMs).toISOString(), - auto_prefix_streams: true, + auto_prefix_streams: tokenScope.stream.autoPrefixStreams, }), }); if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`S2 issue access token failed: ${res.status} ${res.statusText} ${text}`); + await cancelResponseBody(res); + throw new Error(`S2 issue access token failed: ${res.status} ${res.statusText}`); } const data = (await res.json()) as S2IssueAccessTokenResponse; return data.access_token; @@ -766,8 +767,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { }); if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`S2 stream failed: ${res.status} ${res.statusText} ${text}`); + await cancelResponseBody(res); + throw new Error(`S2 stream failed: ${res.status} ${res.statusText}`); } const headers = new Headers(res.headers); @@ -790,6 +791,12 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { } } +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch {} +} + // Pulls the underlying network error out of undici's generic "fetch failed". // undici sets `error.cause` to either a SystemError-shaped object with `code` // (e.g. `ECONNRESET`, `UND_ERR_SOCKET`, `ETIMEDOUT`), `errno`, and `syscall`, diff --git a/apps/webapp/app/utils.ts b/apps/webapp/app/utils.ts index 76fbcb4a260..a3fb5225e57 100644 --- a/apps/webapp/app/utils.ts +++ b/apps/webapp/app/utils.ts @@ -36,7 +36,9 @@ export function sanitizeRedirectPath( return defaultRedirect; } - if (!path.startsWith("/") || path.startsWith("//")) { + const pathnameEnd = path.search(/[?#]/); + const rawPathname = pathnameEnd === -1 ? path : path.slice(0, pathnameEnd); + if (!path.startsWith("/") || path.startsWith("//") || rawPathname.includes("\\")) { return defaultRedirect; } diff --git a/apps/webapp/app/utils/batchItemAuthorization.ts b/apps/webapp/app/utils/batchItemAuthorization.ts index 45b1988877d..b878f808236 100644 --- a/apps/webapp/app/utils/batchItemAuthorization.ts +++ b/apps/webapp/app/utils/batchItemAuthorization.ts @@ -36,9 +36,10 @@ export function batchPublicAccessScopes( export async function authorizedBatchItemStream( items: AsyncIterable, ability: RbacAbility, - batchId: string + batchId: string, + allowStoredPayloads = true ): Promise> { - const authorized = authorizeBatchItems(items, ability, batchId); + const authorized = authorizeBatchItems(items, ability, batchId, allowStoredPayloads); // A batch-level write grant is authorization in its own right, so an empty // stream stays legal for credentials that own the batch: root keys (via the @@ -77,11 +78,26 @@ export async function authorizedBatchItemStream( export async function* authorizeBatchItems( items: AsyncIterable, ability: RbacAbility, - batchId: string + batchId: string, + allowStoredPayloads = true ): AsyncIterable { const canWriteBatch = ability.can("write", { type: "batch", id: batchId }); for await (const item of items) { + const payloadType = + typeof item === "object" && + item !== null && + "options" in item && + typeof item.options === "object" && + item.options !== null && + "payloadType" in item.options + ? item.options.payloadType + : undefined; + + if (!allowStoredPayloads && payloadType === "application/store") { + throw new BatchItemAuthorizationError(); + } + const task = typeof item === "object" && item !== null && "task" in item && typeof item.task === "string" ? item.task diff --git a/apps/webapp/app/utils/runReadAuthorization.test.ts b/apps/webapp/app/utils/runReadAuthorization.test.ts new file mode 100644 index 00000000000..7fc1f1ca349 --- /dev/null +++ b/apps/webapp/app/utils/runReadAuthorization.test.ts @@ -0,0 +1,55 @@ +import { buildJwtAbility } from "@trigger.dev/rbac"; +import { describe, expect, it } from "vitest"; +import { canReadRunWithAliases } from "./runReadAuthorization"; + +const run = { + friendlyId: "run_child", + taskIdentifier: "child-task", + runTags: ["user:alice", "paid"], + batchId: "abcdefghijklmnopqrstuvwx", +}; + +describe("canReadRunWithAliases", () => { + it.each([ + "read:runs:run_child", + "read:tasks:child-task", + "read:tags:user:alice", + "read:batch:batch_abcdefghijklmnopqrstuvwx", + "read:runs", + ])("allows child metadata through the %s alias", (scope) => { + expect(canReadRunWithAliases(buildJwtAbility([scope]), run)).toBe(true); + }); + + it.each([ + "read:runs:run_parent", + "read:tasks:parent-task", + "read:tags:user:bob", + "read:batch:batch_other", + ])("denies child metadata when only %s is authorized", (scope) => { + expect(canReadRunWithAliases(buildJwtAbility([scope]), run)).toBe(false); + }); + + it("checks all 13 bounded aliases in one ability call", () => { + const runWithMaxTags = { + ...run, + runTags: Array.from({ length: 10 }, (_, index) => `tag:${index}`), + }; + let checks = 0; + + expect( + canReadRunWithAliases( + { + can(action, resources) { + checks++; + expect(action).toBe("read"); + expect(resources).toHaveLength(13); + return false; + }, + canSuper: () => false, + }, + runWithMaxTags + ) + ).toBe(false); + expect(checks).toBe(1); + }); +}); diff --git a/apps/webapp/app/utils/runReadAuthorization.ts b/apps/webapp/app/utils/runReadAuthorization.ts new file mode 100644 index 00000000000..5fcf332dc0f --- /dev/null +++ b/apps/webapp/app/utils/runReadAuthorization.ts @@ -0,0 +1,23 @@ +import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; +import { BatchId } from "@trigger.dev/core/v3/isomorphic"; + +type RunReadAliases = { + friendlyId: string; + taskIdentifier: string; + runTags: string[]; + batchId: string | null; +}; + +export function canReadRunWithAliases(ability: RbacAbility, run: RunReadAliases): boolean { + const resources: RbacResource[] = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + + if (run.batchId) { + resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) }); + } + + return ability.can("read", resources); +} diff --git a/apps/webapp/app/utils/sanitizeHttpUrl.ts b/apps/webapp/app/utils/sanitizeHttpUrl.ts index 0e1241fa4c6..52e9ade0502 100644 --- a/apps/webapp/app/utils/sanitizeHttpUrl.ts +++ b/apps/webapp/app/utils/sanitizeHttpUrl.ts @@ -1,5 +1,38 @@ const AUTHORIZATION_CODE_PATH_PREFIX = "/account/authorization-code/"; const REDACTED_AUTHORIZATION_CODE_PATH = "/account/authorization-code/[redacted]"; +const ROUTER_PATH = Symbol.for("trigger.routerPath"); + +type RequestTarget = { + originalUrl?: string; + url?: string; + [ROUTER_PATH]?: string | null; +}; + +export function getRouterPath(request: RequestTarget): string | undefined { + const cached = request[ROUTER_PATH]; + if (cached !== undefined) { + return cached ?? undefined; + } + + const target = request.originalUrl ?? request.url; + if (!target) { + request[ROUTER_PATH] = null; + return undefined; + } + + try { + const pathname = new URL(target, "http://localhost").pathname; + request[ROUTER_PATH] = pathname; + return pathname; + } catch { + request[ROUTER_PATH] = null; + return undefined; + } +} + +export function pathHasPrefix(pathname: string, prefix: string): boolean { + return pathname === prefix || pathname.startsWith(`${prefix}/`); +} export function sanitizeHttpUrl(value: string): string { const queryIndex = value.indexOf("?"); diff --git a/apps/webapp/app/utils/sanitizeRedirectPath.test.ts b/apps/webapp/app/utils/sanitizeRedirectPath.test.ts new file mode 100644 index 00000000000..3f3b8ad90c4 --- /dev/null +++ b/apps/webapp/app/utils/sanitizeRedirectPath.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeRedirectPath } from "../utils"; + +describe("sanitizeRedirectPath", () => { + it("preserves a valid dashboard pathname and search", () => { + expect(sanitizeRedirectPath("/orgs/acme/projects/demo/runs?status=FAILED&page=2")).toBe( + "/orgs/acme/projects/demo/runs?status=FAILED&page=2" + ); + }); + + it.each([ + "https://example.com/collect", + "//example.com/collect", + "/\\example.com/collect", + "javascript:alert(1)", + ])("uses the fallback for unsafe destinations", (destination) => { + expect(sanitizeRedirectPath(destination, "/dashboard")).toBe("/dashboard"); + }); + + it.each([ + "/orgs/acme/projects/demo/runs?filter=folder\\name", + "/orgs/acme/projects/demo/runs#folder\\name", + ])("preserves literal backslashes outside the pathname", (destination) => { + expect(sanitizeRedirectPath(destination)).toBe(destination); + }); + + it("uses the fallback for non-navigable resource routes", () => { + expect(sanitizeRedirectPath("/resources/feedback", "/dashboard")).toBe("/dashboard"); + }); +}); diff --git a/apps/webapp/app/utils/sse.server.ts b/apps/webapp/app/utils/sse.server.ts deleted file mode 100644 index c8ecce4a859..00000000000 --- a/apps/webapp/app/utils/sse.server.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { eventStream } from "remix-utils/sse/server"; -import { env } from "~/env.server"; -import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; -import { logger } from "~/services/logger.server"; - -type SseProps = { - request: Request; - pingInterval?: number; - updateInterval?: number; - run: (send: (event: Event) => void, stop: () => void) => void; -}; - -type Event = { - /** - * @default "update" - */ - event?: string; - data: string; -}; - -export function sse({ request, pingInterval = 1000, updateInterval = 348, run }: SseProps) { - if (env.DISABLE_SSE === "1" || env.DISABLE_SSE === "true") { - return new Response("SSE disabled", { status: 200 }); - } - - const signal = getRequestAbortSignal(); - - let pinger: NodeJS.Timeout | undefined = undefined; - let updater: NodeJS.Timeout | undefined = undefined; - let timeout: NodeJS.Timeout | undefined = undefined; - - const abort = () => { - clearInterval(pinger); - clearInterval(updater); - clearTimeout(timeout); - }; - - return eventStream(signal, (send, close) => { - const safeSend = (args: { event?: string; data: string }) => { - try { - send(args); - } catch (error) { - if (error instanceof Error) { - if (error.name !== "TypeError") { - logger.debug("Error sending SSE, aborting", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - args, - }); - } - } else { - logger.debug("Unknown error sending SSE, aborting", { - error, - args, - }); - } - - close(); - } - }; - - pinger = setInterval(() => { - if (signal.aborted) { - return abort(); - } - - safeSend({ event: "ping", data: new Date().toISOString() }); - }, pingInterval); - - updater = setInterval(() => { - if (signal.aborted) { - return abort(); - } - - run(safeSend, abort); - }, updateInterval); - - timeout = setTimeout(() => { - close(); // close the connection after 1 minute of inactivity, which will refresh the connection (that's why we aren't using abort) - }, 60 * 1000); // 1 minute - - return abort; - }); -} diff --git a/apps/webapp/app/utils/timeGranularity.ts b/apps/webapp/app/utils/timeGranularity.ts index 4e4d542de3a..8693fc11598 100644 --- a/apps/webapp/app/utils/timeGranularity.ts +++ b/apps/webapp/app/utils/timeGranularity.ts @@ -59,3 +59,21 @@ export class TimeGranularity { return this.parsed[this.parsed.length - 1].granularityMs; } } + +const MAX_CHART_BUCKETS = 10_000; + +/** + * Rejects user-supplied chart ranges before they reach a query or a bucket + * loop: non-finite or inverted dates, or a span that would generate an + * unbounded number of buckets at the chosen granularity. + */ +export function isBoundedChartRange(from: Date, to: Date, granularityMs: number): boolean { + const fromMs = from.getTime(); + const toMs = to.getTime(); + return ( + Number.isFinite(fromMs) && + Number.isFinite(toMs) && + fromMs <= toMs && + (toMs - fromMs) / granularityMs <= MAX_CHART_BUCKETS + ); +} diff --git a/apps/webapp/app/v3/freeSchedulePolicy.server.ts b/apps/webapp/app/v3/freeSchedulePolicy.server.ts index cd51f6d38d9..239b0f85c9c 100644 --- a/apps/webapp/app/v3/freeSchedulePolicy.server.ts +++ b/apps/webapp/app/v3/freeSchedulePolicy.server.ts @@ -1,6 +1,7 @@ import { metrics } from "@opentelemetry/api"; -import { flag } from "~/v3/featureFlags.server"; +import { makeFlag } from "~/v3/featureFlags.server"; import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { type PrismaClientOrTransaction } from "~/db.server"; import { getCurrentPlan, isBillingConfigured } from "~/services/platform.v3.server"; import { ServiceValidationError } from "./services/baseService.server"; import { @@ -33,10 +34,13 @@ const cronRejectionCounter = meter.createCounter("free_schedule_policy.cron_reje }); /** Resolve once per request or sync: getCurrentPlan is an uncached remote call. */ -export async function resolveFreeSchedulePolicyContext(organization: { - id: string; - featureFlags: unknown; -}): Promise { +export async function resolveFreeSchedulePolicyContext( + prisma: PrismaClientOrTransaction, + organization: { + id: string; + featureFlags: unknown; + } +): Promise { const overrides = organization.featureFlags && typeof organization.featureFlags === "object" && @@ -44,7 +48,7 @@ export async function resolveFreeSchedulePolicyContext(organization: { ? (organization.featureFlags as Record) : undefined; - const flagEnabled = await flag({ + const flagEnabled = await makeFlag(prisma)({ key: FEATURE_FLAG.freeScheduleMinimumWindowEnabled, defaultValue: false, overrides, diff --git a/apps/webapp/app/v3/github/githubSettingsRedirect.server.ts b/apps/webapp/app/v3/github/githubSettingsRedirect.server.ts new file mode 100644 index 00000000000..1809a1a7b92 --- /dev/null +++ b/apps/webapp/app/v3/github/githubSettingsRedirect.server.ts @@ -0,0 +1,48 @@ +import { sanitizeRedirectPath } from "~/utils"; + +function isVercelHostname(hostname: string): boolean { + return hostname === "vercel.com" || hostname.endsWith(".vercel.com"); +} + +export function sanitizeGitHubSettingsRedirect( + redirectUrl: string | undefined, + applicationOrigin: string +): string | undefined { + if (!redirectUrl) { + return undefined; + } + + if (redirectUrl.startsWith("/")) { + const sanitized = sanitizeRedirectPath(redirectUrl, ""); + return sanitized || undefined; + } + + let destination: URL; + let trustedOrigin: string; + try { + destination = new URL(redirectUrl); + trustedOrigin = new URL(applicationOrigin).origin; + } catch { + return undefined; + } + + if (destination.username || destination.password) { + return undefined; + } + + if (destination.origin === trustedOrigin) { + const relativeUrl = `${destination.pathname}${destination.search}${destination.hash}`; + const sanitized = sanitizeRedirectPath(relativeUrl, ""); + return sanitized || undefined; + } + + if ( + destination.protocol === "https:" && + !destination.port && + isVercelHostname(destination.hostname.toLowerCase()) + ) { + return destination.toString(); + } + + return undefined; +} diff --git a/apps/webapp/app/v3/otlpTransform.server.ts b/apps/webapp/app/v3/otlpTransform.server.ts index 4de989b0a45..716a6b33fed 100644 --- a/apps/webapp/app/v3/otlpTransform.server.ts +++ b/apps/webapp/app/v3/otlpTransform.server.ts @@ -12,7 +12,7 @@ import type { import { SeverityNumber, Span_SpanKind, Status_StatusCode } from "@trigger.dev/otlp-importer"; import type { MetricsV1Input } from "@internal/clickhouse"; import { generateSpanId } from "./eventRepository/common.server"; -import { unwrapWorkerIdInMetadata } from "./workerIdUnwrap.server"; +import { unwrapWorkerId, unwrapWorkerIdInMetadata } from "./workerIdUnwrap.server"; import type { CreatableEventKind, CreatableEventStatus, @@ -303,7 +303,9 @@ export function convertMetricsToClickhouseRows( runId: resourceProperties.runId, attemptNumber: resourceProperties.attemptNumber, machineId: extractStringAttribute(resourceAttributes, SemanticInternalAttributes.MACHINE_ID), - workerId: extractStringAttribute(resourceAttributes, SemanticInternalAttributes.WORKER_ID), + workerId: unwrapWorkerId( + extractStringAttribute(resourceAttributes, SemanticInternalAttributes.WORKER_ID) + ), workerVersion: extractStringAttribute( resourceAttributes, SemanticInternalAttributes.WORKER_VERSION @@ -417,7 +419,7 @@ function resolveDataPointContext( extractStringAttribute(dpAttributes, SemanticInternalAttributes.MACHINE_ID); const workerId = resourceCtx.workerId ?? - extractStringAttribute(dpAttributes, SemanticInternalAttributes.WORKER_ID); + unwrapWorkerId(extractStringAttribute(dpAttributes, SemanticInternalAttributes.WORKER_ID)); const workerVersion = resourceCtx.workerVersion ?? extractStringAttribute(dpAttributes, SemanticInternalAttributes.WORKER_VERSION); diff --git a/apps/webapp/app/v3/services/aiRunFilterService.server.ts b/apps/webapp/app/v3/services/aiRunFilterService.server.ts index c3bc2b10e8b..eea9e4b90fe 100644 --- a/apps/webapp/app/v3/services/aiRunFilterService.server.ts +++ b/apps/webapp/app/v3/services/aiRunFilterService.server.ts @@ -307,17 +307,9 @@ export class AIRunFilterService { environmentId, }); - // If it's a schema validation error, provide more specific feedback - if (error instanceof Error && error.message.includes("schema")) { - return { - success: false, - error: error.message, - }; - } - return { success: false, - error: error instanceof Error ? error.message : String(error), + error: "Unable to create filters", }; } } diff --git a/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts index 6234a92cec6..4850d13f7a2 100644 --- a/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts @@ -24,29 +24,12 @@ import { logger } from "~/services/logger.server"; import { decryptSecret } from "~/services/secrets/secretStore.server"; import { subtle } from "crypto"; import { generateErrorGroupWebhookPayload } from "./errorGroupWebhook.server"; +import { + buildErrorGroupSlackMessage, + type ErrorAlertPayload, +} from "./errorGroupSlackMessage.server"; import { safeWebhookFetch } from "./safeWebhookFetch.server"; -type ErrorAlertClassification = "new_issue" | "regression" | "unignored"; - -interface ErrorAlertPayload { - channelId: string; - projectId: string; - classification: ErrorAlertClassification; - error: { - fingerprint: string; - environmentId: string; - environmentSlug: string; - environmentName: string; - taskIdentifier: string; - errorType: string; - errorMessage: string; - sampleStackTrace: string; - firstSeen: string; - lastSeen: string; - occurrenceCount: number; - }; -} - class SkipRetryError extends Error {} export class DeliverErrorGroupAlertService { @@ -108,17 +91,6 @@ export class DeliverErrorGroupAlertService { return `${env.APP_ORIGIN}${v3ErrorPath(organization, project, { slug: error.environmentSlug }, { fingerprint: error.fingerprint })}`; } - #classificationLabel(classification: ErrorAlertClassification): string { - switch (classification) { - case "new_issue": - return "New error"; - case "regression": - return "Regression"; - case "unignored": - return "Error resurfaced"; - } - } - async #sendEmail( channel: { type: ProjectAlertChannelType; @@ -193,7 +165,7 @@ export class DeliverErrorGroupAlertService { return; } - const message = this.#buildErrorGroupSlackMessage(payload, errorLink, channel.project.name); + const message = buildErrorGroupSlackMessage(payload, errorLink, channel.project.name); await this.#postSlackMessage(integration, { channel: slackProperties.data.channelId, @@ -308,110 +280,6 @@ export class DeliverErrorGroupAlertService { throw error; } } - - #buildErrorGroupSlackMessage( - payload: ErrorAlertPayload, - errorLink: string, - projectName: string - ): { text: string; blocks: object[]; attachments: object[] } { - const label = this.#classificationLabel(payload.classification); - const errorType = payload.error.errorType || "Error"; - const task = payload.error.taskIdentifier; - const envName = payload.error.environmentName; - - return { - text: `${label}: ${errorType} in ${task} [${envName}]`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `*${label} in ${task} [${envName}]*`, - }, - }, - ], - attachments: [ - { - color: "danger", - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: this.#wrapInCodeBlock( - payload.error.sampleStackTrace || payload.error.errorMessage - ), - }, - }, - { - type: "section", - fields: [ - { - type: "mrkdwn", - text: `*Task:*\n${task}`, - }, - { - type: "mrkdwn", - text: `*Environment:*\n${envName}`, - }, - { - type: "mrkdwn", - text: `*Project:*\n${projectName}`, - }, - { - type: "mrkdwn", - text: `*Occurrences:*\n${payload.error.occurrenceCount}`, - }, - { - type: "mrkdwn", - text: `*Last seen:*\n${this.#formatTimestamp(new Date(Number(payload.error.lastSeen)))}`, - }, - ], - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { type: "plain_text", text: "Investigate" }, - url: errorLink, - style: "primary", - }, - ], - }, - ], - }, - ], - }; - } - - #wrapInCodeBlock(text: string, maxLength = 3000) { - const wrapperLength = 6; // ``` prefix + ``` suffix - const truncationSuffix = "\n\n...truncated — check dashboard for full error"; - const innerMax = maxLength - wrapperLength; - - const truncated = - text.length > innerMax - ? text.slice(0, innerMax - truncationSuffix.length) + truncationSuffix - : text; - return `\`\`\`${truncated}\`\`\``; - } - - #formatTimestamp(date: Date): string { - const unix = Math.floor(date.getTime() / 1000); - const fallback = - new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - timeZone: "UTC", - }).format(date) + " UTC"; - return ``; - } } function isWebAPIPlatformError(error: unknown): error is WebAPIPlatformError { diff --git a/apps/webapp/app/v3/services/alerts/errorGroupSlackMessage.server.test.ts b/apps/webapp/app/v3/services/alerts/errorGroupSlackMessage.server.test.ts new file mode 100644 index 00000000000..b709c06cf0c --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/errorGroupSlackMessage.server.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + buildErrorGroupSlackMessage, + type ErrorAlertPayload, +} from "./errorGroupSlackMessage.server"; + +const payload: ErrorAlertPayload = { + channelId: "channel", + projectId: "project", + classification: "new_issue", + error: { + fingerprint: "fingerprint", + environmentId: "environment", + environmentSlug: "prod", + environmentName: "", + taskIdentifier: "", + errorType: "Error & warning", + errorMessage: "message", + sampleStackTrace: "```\n\n" + "x".repeat(4000), + firstSeen: "0", + lastSeen: "1700000000000", + occurrenceCount: 3, + }, +}; + +describe("buildErrorGroupSlackMessage", () => { + it("bounds untrusted text without interpreting it as Slack markup", () => { + const message = buildErrorGroupSlackMessage( + payload, + "https://example.com/errors/1", + "" + ); + const heading = message.blocks[0] as { + text: { type: string; text: string }; + }; + const attachment = message.attachments[0] as { + blocks: Array<{ + text?: { type: string; text: string }; + fields?: Array<{ type: string; text: string }>; + elements?: Array<{ url: string }>; + }>; + }; + const error = attachment.blocks[0].text!; + const fields = attachment.blocks[1].fields!; + + expect(message.text.length).toBeLessThanOrEqual(4000); + expect(message.mrkdwn).toBe(false); + expect(message.parse).toBe("none"); + expect(message.text).not.toContain(""); + expect(message.text).toContain("<!channel>"); + expect(heading.text.type).toBe("plain_text"); + expect(heading.text.text.length).toBeLessThanOrEqual(3000); + expect(error.type).toBe("plain_text"); + expect(error.text.length).toBe(3000); + expect(error.text).toContain(""); + expect(fields.slice(0, 4).every((field) => field.type === "plain_text")).toBe(true); + expect(fields.slice(0, 3).every((field) => field.text.length <= 2000)).toBe(true); + }); + + it("keeps generated links and date formatting separate from untrusted fields", () => { + const message = buildErrorGroupSlackMessage(payload, "https://example.com/errors/1", "project"); + const attachment = message.attachments[0] as { + blocks: Array<{ + fields?: Array<{ type: string; text: string }>; + elements?: Array<{ url: string }>; + }>; + }; + + expect(attachment.blocks[1].fields?.[4].type).toBe("mrkdwn"); + expect(attachment.blocks[1].fields?.[4].text).toContain("", ">"); +} + +function classificationLabel(classification: ErrorAlertClassification): string { + switch (classification) { + case "new_issue": + return "New error"; + case "regression": + return "Regression"; + case "unignored": + return "Error resurfaced"; + } +} + +function formatSlackTimestamp(date: Date): string { + const unix = Math.floor(date.getTime() / 1000); + const fallback = + new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, + timeZone: "UTC", + }).format(date) + " UTC"; + return ``; +} + +export function buildErrorGroupSlackMessage( + payload: ErrorAlertPayload, + errorLink: string, + projectName: string +): { text: string; mrkdwn: false; parse: "none"; blocks: object[]; attachments: object[] } { + const label = classificationLabel(payload.classification); + const errorType = payload.error.errorType || "Error"; + const task = payload.error.taskIdentifier; + const envName = payload.error.environmentName; + const fallbackText = escapeSlackMrkdwn(`${label}: ${errorType} in ${task} [${envName}]`); + + return { + text: truncateSlackText(fallbackText, SLACK_FALLBACK_TEXT_MAX_LENGTH), + // Top-level `text` is parsed as mrkdwn with URL auto-linking by default; disable both so + // untrusted error/task/env values render literally in notification and screen-reader previews. + mrkdwn: false, + parse: "none", + blocks: [ + { + type: "section", + text: { + type: "plain_text", + text: truncateSlackText( + `${label} in ${task} [${envName}]`, + SLACK_SECTION_TEXT_MAX_LENGTH + ), + }, + }, + ], + attachments: [ + { + color: "danger", + blocks: [ + { + type: "section", + text: { + type: "plain_text", + text: truncateSlackText( + payload.error.sampleStackTrace || payload.error.errorMessage || errorType, + SLACK_SECTION_TEXT_MAX_LENGTH + ), + }, + }, + { + type: "section", + fields: [ + { + type: "plain_text", + text: truncateSlackText(`Task:\n${task}`, SLACK_FIELD_TEXT_MAX_LENGTH), + }, + { + type: "plain_text", + text: truncateSlackText(`Environment:\n${envName}`, SLACK_FIELD_TEXT_MAX_LENGTH), + }, + { + type: "plain_text", + text: truncateSlackText(`Project:\n${projectName}`, SLACK_FIELD_TEXT_MAX_LENGTH), + }, + { + type: "plain_text", + text: `Occurrences:\n${payload.error.occurrenceCount}`, + }, + { + type: "mrkdwn", + text: `*Last seen:*\n${formatSlackTimestamp(new Date(Number(payload.error.lastSeen)))}`, + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Investigate" }, + url: errorLink, + style: "primary", + }, + ], + }, + ], + }, + ], + }; +} diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index cb2b6701157..06b2e3454fc 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -986,7 +986,7 @@ async function prepareDeclarativeSchedules( : null; const policy = hasNewSchedules || hasRestrictedSchedules - ? await resolveFreeSchedulePolicyContext({ + ? await resolveFreeSchedulePolicyContext(prisma, { id: environment.organizationId, featureFlags: environment.organization.featureFlags, }) diff --git a/apps/webapp/app/v3/services/dashboardEnvironmentAccess.server.ts b/apps/webapp/app/v3/services/dashboardEnvironmentAccess.server.ts new file mode 100644 index 00000000000..16228af505c --- /dev/null +++ b/apps/webapp/app/v3/services/dashboardEnvironmentAccess.server.ts @@ -0,0 +1,31 @@ +import type { PrismaClientOrTransaction } from "@trigger.dev/database"; +import type { HostRbacController } from "@trigger.dev/rbac"; +import { throwPermissionDenied } from "~/utils/permissionDenied"; + +/** Resolve tenancy on the writer before asking the optional plugin for an ability. */ +export async function dashboardEnvironmentAccess( + database: PrismaClientOrTransaction, + controller: Pick, + request: Request, + userId: string, + environmentId: string +) { + const environment = await database.runtimeEnvironment.findFirst({ + where: { + id: environmentId, + archivedAt: null, + project: { deletedAt: null }, + organization: { deletedAt: null, members: { some: { userId } } }, + }, + select: { id: true, type: true, projectId: true, organizationId: true }, + }); + if (!environment) throw new Response("Environment not found", { status: 404 }); + + const auth = await controller.authenticateSession(request, { + userId, + organizationId: environment.organizationId, + projectId: environment.projectId, + }); + if (!auth.ok) throwPermissionDenied(); + return { environment, ability: auth.ability }; +} diff --git a/apps/webapp/app/v3/services/dashboardEnvironmentAccess.test.ts b/apps/webapp/app/v3/services/dashboardEnvironmentAccess.test.ts new file mode 100644 index 00000000000..eb468b495f2 --- /dev/null +++ b/apps/webapp/app/v3/services/dashboardEnvironmentAccess.test.ts @@ -0,0 +1,105 @@ +import { postgresTest } from "@internal/testcontainers"; +import rbac from "@trigger.dev/rbac"; +import { expect } from "vitest"; +import { dashboardEnvironmentAccess } from "./dashboardEnvironmentAccess.server"; + +postgresTest( + "the writer resolves the actual tenant and tier; self-hosted members retain permissive abilities", + async ({ prisma }) => { + const member = await prisma.user.create({ + data: { + email: "runtime-member@example.test", + authenticationMethod: "MAGIC_LINK", + admin: false, + }, + }); + const outsider = await prisma.user.create({ + data: { email: "runtime-outsider@example.test", authenticationMethod: "MAGIC_LINK" }, + }); + const organization = await prisma.organization.create({ + data: { + slug: "runtime-auth", + title: "Runtime auth", + members: { create: { userId: member.id, role: "MEMBER" } }, + }, + }); + const project = await prisma.project.create({ + data: { + slug: "runtime-auth", + name: "Runtime auth", + externalRef: "runtime-auth", + organizationId: organization.id, + }, + }); + const controller = rbac.create(prisma, { forceFallback: true }); + const request = new Request("https://example.test/orgs/wrong/projects/wrong/env/dev"); + + for (const type of ["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"] as const) { + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug: type, + type, + shortcode: type, + apiKey: `key_${type}`, + pkApiKey: `pk_${type}`, + projectId: project.id, + organizationId: organization.id, + }, + }); + const access = await dashboardEnvironmentAccess( + prisma, + controller, + request, + member.id, + environment.id + ); + expect(access.environment).toEqual({ + id: environment.id, + type, + organizationId: organization.id, + projectId: project.id, + }); + expect(access.ability.can("write", { type: "tasks", envType: type })).toBe(true); + await expect( + dashboardEnvironmentAccess(prisma, controller, request, outsider.id, environment.id) + ).rejects.toMatchObject({ status: 404 }); + await expect( + dashboardEnvironmentAccess(prisma, controller, request, "", environment.id) + ).rejects.toMatchObject({ status: 404 }); + // Each inactive ancestor must independently reject an otherwise valid member. + for (const inactiveTarget of ["environment", "project", "organization"] as const) { + if (inactiveTarget === "environment") { + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { archivedAt: new Date() }, + }); + } else if (inactiveTarget === "project") { + await prisma.project.update({ + where: { id: project.id }, + data: { deletedAt: new Date() }, + }); + } else { + await prisma.organization.update({ + where: { id: organization.id }, + data: { deletedAt: new Date() }, + }); + } + await expect( + dashboardEnvironmentAccess(prisma, controller, request, member.id, environment.id) + ).rejects.toMatchObject({ status: 404 }); + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { archivedAt: null }, + }); + await prisma.project.update({ where: { id: project.id }, data: { deletedAt: null } }); + await prisma.organization.update({ + where: { id: organization.id }, + data: { deletedAt: null }, + }); + } + } + await expect( + dashboardEnvironmentAccess(prisma, controller, request, member.id, "missing") + ).rejects.toMatchObject({ status: 404 }); + } +); diff --git a/apps/webapp/app/v3/services/dashboardResourceAccess.server.ts b/apps/webapp/app/v3/services/dashboardResourceAccess.server.ts new file mode 100644 index 00000000000..5f48700e81c --- /dev/null +++ b/apps/webapp/app/v3/services/dashboardResourceAccess.server.ts @@ -0,0 +1,73 @@ +import { type PrismaClientOrTransaction, RuntimeEnvironmentType } from "@trigger.dev/database"; +import type { HostRbacController } from "@trigger.dev/rbac"; +import { throwPermissionDenied } from "~/utils/permissionDenied"; + +export async function dashboardResourceAccess( + database: PrismaClientOrTransaction, + controller: Pick, + request: Request, + userId: string, + scope: { organizationId: string; projectId?: string; environmentId?: string } +) { + const organization = await database.organization.findFirst({ + where: { id: scope.organizationId, deletedAt: null, members: { some: { userId } } }, + select: { id: true }, + }); + if (!organization) throw new Response("Organization not found", { status: 404 }); + // Both lookups depend only on the validated organization and supplied scope. + const [project, environment] = await Promise.all([ + scope.projectId + ? database.project.findFirst({ + where: { id: scope.projectId, organizationId: organization.id, deletedAt: null }, + select: { id: true }, + }) + : undefined, + scope.environmentId + ? database.runtimeEnvironment.findFirst({ + where: { + id: scope.environmentId, + organizationId: organization.id, + projectId: scope.projectId, + archivedAt: null, + project: { deletedAt: null }, + }, + select: { id: true, type: true, projectId: true }, + }) + : undefined, + ]); + if (scope.projectId && !project) throw new Response("Project not found", { status: 404 }); + if (scope.environmentId && !environment) + throw new Response("Environment not found", { status: 404 }); + const auth = await controller.authenticateSession(request, { + userId, + organizationId: organization.id, + projectId: environment?.projectId ?? scope.projectId, + }); + if (!auth.ok) throwPermissionDenied(); + const can = (action: string, type: string) => + auth.ability.can(action, { type, ...(environment ? { envType: environment.type } : {}) }); + const canAcrossEnvironments = ( + action: string, + type: string, + environmentTypes: readonly RuntimeEnvironmentType[] + ) => + (environmentTypes.length ? environmentTypes : Object.values(RuntimeEnvironmentType)).every( + (envType) => auth.ability.can(action, { type, envType }) + ); + return { + can, + canAcrossEnvironments, + requireAcrossEnvironments( + action: string, + type: string, + environmentTypes: readonly RuntimeEnvironmentType[] + ) { + if (!canAcrossEnvironments(action, type, environmentTypes)) + throwPermissionDenied("You don't have permission in every affected environment."); + }, + require(action: string, type: string) { + if (!can(action, type)) + throwPermissionDenied("You don't have permission to perform this action."); + }, + }; +} diff --git a/apps/webapp/app/v3/services/dashboardResourceAccess.test.ts b/apps/webapp/app/v3/services/dashboardResourceAccess.test.ts new file mode 100644 index 00000000000..46ee3252a3e --- /dev/null +++ b/apps/webapp/app/v3/services/dashboardResourceAccess.test.ts @@ -0,0 +1,83 @@ +import { postgresTest } from "@internal/testcontainers"; +import rbac from "@trigger.dev/rbac"; +import { expect } from "vitest"; +import { dashboardResourceAccess } from "./dashboardResourceAccess.server"; + +postgresTest( + "resource access resolves writer tenancy before permissive fallback", + async ({ prisma }) => { + const user = await prisma.user.create({ + data: { email: "resource@example.test", authenticationMethod: "MAGIC_LINK" }, + }); + const org = await prisma.organization.create({ + data: { + slug: "resource", + title: "Resource", + members: { create: { userId: user.id, role: "MEMBER" } }, + }, + }); + const project = await prisma.project.create({ + data: { slug: "resource", name: "Resource", externalRef: "resource", organizationId: org.id }, + }); + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug: "prod", + shortcode: "prod", + type: "PRODUCTION", + apiKey: "key", + pkApiKey: "pk", + projectId: project.id, + organizationId: org.id, + }, + }); + const controller = rbac.create(prisma, { forceFallback: true }); + const request = new Request("https://example.test/env/dev"); + const scope = { organizationId: org.id, projectId: project.id, environmentId: environment.id }; + const access = await dashboardResourceAccess(prisma, controller, request, user.id, scope); + for (const subject of [ + "webhooks", + "alerts", + "dashboards", + "errors", + "query", + "sessions", + "slack", + "privateConnections", + "dashboardAgent", + ]) { + expect(access.can("write", subject)).toBe(true); + expect(() => access.require("write", subject)).not.toThrow(); + } + for (const invalid of [ + { ...scope, organizationId: "foreign" }, + { ...scope, projectId: "foreign" }, + { ...scope, environmentId: "foreign" }, + ]) { + await expect( + dashboardResourceAccess(prisma, controller, request, user.id, invalid) + ).rejects.toMatchObject({ status: 404 }); + } + await expect( + dashboardResourceAccess(prisma, controller, request, "outsider", scope) + ).rejects.toMatchObject({ status: 404 }); + // Environment-only scopes must enforce the owning project's lifecycle too. + const environmentScope = { organizationId: org.id, environmentId: environment.id }; + await expect( + dashboardResourceAccess(prisma, controller, request, user.id, environmentScope) + ).resolves.toBeDefined(); + await prisma.project.update({ where: { id: project.id }, data: { deletedAt: new Date() } }); + for (const deletedProjectScope of [scope, environmentScope]) { + await expect( + dashboardResourceAccess(prisma, controller, request, user.id, deletedProjectScope) + ).rejects.toMatchObject({ status: 404 }); + } + await prisma.project.update({ where: { id: project.id }, data: { deletedAt: null } }); + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { archivedAt: new Date() }, + }); + await expect( + dashboardResourceAccess(prisma, controller, request, user.id, scope) + ).rejects.toMatchObject({ status: 404 }); + } +); diff --git a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts index acc8cbb537a..91f9c9bdfd0 100644 --- a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts @@ -66,13 +66,28 @@ export class FinalizeDeploymentV2Service extends BaseService { throw new ServiceValidationError("Worker deployment is not in DEPLOYING status"); } + const deploymentDiagnostics = { + id: deployment.id, + friendlyId: id, + status: deployment.status, + version: deployment.version, + imageReference: deployment.imageReference, + type: deployment.type, + }; + const finalizeOptions = { + skipPromotion: body.skipPromotion, + imageDigest: body.imageDigest, + skipPushToRegistry: body.skipPushToRegistry, + }; + const finalizeService = new FinalizeDeploymentService(); // If remote builds are not enabled, skip image push and go straight to template + finalize if (!remoteBuildsEnabled() || body.skipPushToRegistry) { if (body.skipPushToRegistry) { logger.debug("Skipping push to registry during deployment finalization", { - deployment, + deployment: deploymentDiagnostics, + finalizeOptions, }); } @@ -117,7 +132,10 @@ export class FinalizeDeploymentV2Service extends BaseService { throw new ServiceValidationError("Missing image reference"); } - logger.debug("Pushing image to registry", { id, deployment, body }); + logger.debug("Pushing image to registry", { + deployment: deploymentDiagnostics, + finalizeOptions, + }); const pushResult = await executePushToRegistry( { @@ -142,9 +160,8 @@ export class FinalizeDeploymentV2Service extends BaseService { } logger.debug("Image pushed to registry", { - id, - deployment, - body, + deployment: deploymentDiagnostics, + finalizeOptions, pushedImage: pushResult.image, }); diff --git a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts index 0a1486c68ad..276e76c4e87 100644 --- a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts +++ b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts @@ -56,7 +56,7 @@ export class UpsertTaskScheduleService extends BaseService { throw new ServiceValidationError("Project not found"); } - const policy = await resolveFreeSchedulePolicyContext({ + const policy = await resolveFreeSchedulePolicyContext(this._prisma, { id: project.organizationId, featureFlags: project.organization.featureFlags, }); diff --git a/apps/webapp/app/v3/vercel/vercelApiError.ts b/apps/webapp/app/v3/vercel/vercelApiError.ts new file mode 100644 index 00000000000..5e9d1667a17 --- /dev/null +++ b/apps/webapp/app/v3/vercel/vercelApiError.ts @@ -0,0 +1,33 @@ +import { vercelErrorLogMetadata } from "./vercelErrorLogMetadata"; + +export const VERCEL_API_ERROR_MESSAGE = "Vercel API request failed"; + +export type VercelApiError = { + message: string; + authInvalid: boolean; +}; + +function isVercelApiError(error: unknown): error is VercelApiError { + return ( + error !== null && + typeof error === "object" && + "message" in error && + "authInvalid" in error && + typeof (error as VercelApiError).message === "string" && + typeof (error as VercelApiError).authInvalid === "boolean" + ); +} + +export function toVercelApiError(error: unknown): VercelApiError { + const status = vercelErrorLogMetadata(error).status; + const authInvalid = isVercelApiError(error) + ? error.authInvalid + : status === 401 || + status === 403 || + (typeof error === "string" && (error.includes("401") || error.includes("403"))); + + return { + message: VERCEL_API_ERROR_MESSAGE, + authInvalid, + }; +} diff --git a/apps/webapp/app/v3/vercel/vercelErrorLogMetadata.ts b/apps/webapp/app/v3/vercel/vercelErrorLogMetadata.ts new file mode 100644 index 00000000000..f899a83ffc7 --- /dev/null +++ b/apps/webapp/app/v3/vercel/vercelErrorLogMetadata.ts @@ -0,0 +1,60 @@ +const SAFE_VERCEL_ERROR_NAMES = new Set([ + "BadRequest", + "ConnectionError", + "Error", + "Forbidden", + "HTTPClientError", + "HttpApiDecodeError", + "InternalServerError", + "InvalidRequestError", + "NotAuthorizedForScope", + "NotFound", + "RequestAbortedError", + "RequestTimeoutError", + "ResponseValidationError", + "SDKError", + "SDKValidationError", + "TooManyRequests", + "Unauthorized", + "UnexpectedClientError", + "VercelError", +]); + +function safeErrorName(error: unknown): string { + if (!(error instanceof Error)) return "UnknownError"; + + const candidates = [error.constructor.name, error.name]; + return ( + candidates.find((name) => name !== "Error" && SAFE_VERCEL_ERROR_NAMES.has(name)) ?? "Error" + ); +} + +function validHttpStatus(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599 + ? value + : undefined; +} + +export function vercelErrorLogMetadata(error: unknown): { + errorType: string; + status?: number; +} { + if (!error || typeof error !== "object") { + return { errorType: "UnknownError" }; + } + + const candidate = error as { + status?: unknown; + statusCode?: unknown; + response?: { status?: unknown }; + }; + + const status = + validHttpStatus(candidate.statusCode) ?? + validHttpStatus(candidate.status) ?? + validHttpStatus(candidate.response?.status); + + return status === undefined + ? { errorType: safeErrorName(error) } + : { errorType: safeErrorName(error), status }; +} diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index 557611af908..83099c0e12c 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -12,7 +12,7 @@ import type { Server as IoServer } from "socket.io"; import type { WebSocketServer } from "ws"; import type { RateLimitMiddleware } from "~/services/apiRateLimit.server"; import { type RunWithHttpContextFunction } from "~/services/httpAsyncStorage.server"; -import { sanitizeHttpUrl } from "./app/utils/sanitizeHttpUrl"; +import { getRouterPath, pathHasPrefix, sanitizeHttpUrl } from "./app/utils/sanitizeHttpUrl"; import cluster from "node:cluster"; import os from "node:os"; @@ -235,10 +235,9 @@ async function startServer() { if (process.env.DASHBOARD_AND_API_DISABLED !== "true") { if (process.env.ALLOW_ONLY_REALTIME_API === "true") { - // Block all requests that do not start with /realtime app.use((req, res, next) => { - // Make sure /healthcheck is still accessible - if (!req.url.startsWith("/realtime") && req.url !== "/healthcheck") { + const pathname = getRouterPath(req); + if (pathname !== "/healthcheck" && (!pathname || !pathHasPrefix(pathname, "/realtime"))) { res.status(404).send("Not Found"); return; } @@ -319,41 +318,41 @@ async function startServer() { socketIo?.io.attach(server); server.removeAllListeners("upgrade"); // prevent duplicate upgrades from listeners created by io.attach() - server.on("upgrade", async (req, socket, head) => { + server.on("upgrade", (req, socket, head) => { console.log(`Attemping to upgrade connection at url ${req.url}`); socket.on("error", (err) => { console.error("Connection upgrade error:", err); }); - const url = new URL(req.url ?? "", "http://localhost"); + try { + const pathname = getRouterPath(req); - // Upgrade socket.io connection - if (url.pathname.startsWith("/socket.io/")) { - console.log(`Socket.io client connected, upgrading their connection...`); + if (pathname && pathHasPrefix(pathname, "/socket.io")) { + console.log(`Socket.io client connected, upgrading their connection...`); - // https://github.com/socketio/socket.io/issues/4693 - (socketIo!.io.engine as EngineServer).handleUpgrade(req, socket, head); - return; - } + // https://github.com/socketio/socket.io/issues/4693 + (socketIo!.io.engine as EngineServer).handleUpgrade(req, socket, head); + return; + } - // Only upgrade the connecting if the path is `/ws` - if (url.pathname !== "/ws") { - // Setting the socket.destroy() error param causes an error event to be emitted which needs to be handled with socket.on("error") to prevent uncaught exceptions. - socket.destroy( - new Error( - "Cannot connect because of invalid path: Please include `/ws` in the path of your upgrade request." - ) - ); - return; - } + if (pathname !== "/ws") { + console.warn( + `Rejected upgrade request: path must be /ws, got ${pathname ?? ""}` + ); + socket.destroy(); + return; + } - console.log(`Client connected, upgrading their connection...`); + console.log(`Client connected, upgrading their connection...`); - // Handle the WebSocket connection - wss?.handleUpgrade(req, socket, head, (ws) => { - wss?.emit("connection", ws, req); - }); + wss?.handleUpgrade(req, socket, head, (ws) => { + wss?.emit("connection", ws, req); + }); + } catch (error) { + console.error("Connection upgrade handler failed:", error); + socket.destroy(); + } }); } else { console.log(`✅ app ready (skipping http server)`); diff --git a/apps/webapp/test/api-auth.e2e.test.ts b/apps/webapp/test/api-auth.e2e.test.ts index 58852768bc1..4e8c4346349 100644 --- a/apps/webapp/test/api-auth.e2e.test.ts +++ b/apps/webapp/test/api-auth.e2e.test.ts @@ -70,6 +70,120 @@ describe("API bearer auth — baseline behavior", () => { }); }); +describe("Packet download authorization", () => { + // The auth harness has no object store. Reaching its configuration error proves that + // resource lookup and authorization passed and the handler attempted to presign. + const objectStoreError = { + error: "Failed to generate presigned URL: Object store is not configured for protocol: default", + }; + + async function seedStoredRuns() { + const seed = await seedTestEnvironment(server.prisma); + const first = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const second = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + + await Promise.all([ + server.prisma.taskRun.update({ + where: { id: first.run.id }, + data: { + payload: `${first.runFriendlyId}/payload.json`, + payloadType: "application/store", + output: `${first.runFriendlyId}-1/output.json`, + outputType: "application/store", + }, + }), + server.prisma.taskRun.update({ + where: { id: second.run.id }, + data: { + payload: `${second.runFriendlyId}/payload.json`, + payloadType: "application/store", + }, + }), + ]); + + return { ...seed, first, second }; + } + + it("authorizes run packet downloads against the token's run scope", async () => { + const { environment, first, second } = await seedStoredRuns(); + const firstJwt = await generateTestJWT(environment, { + scopes: [`read:runs:${first.runFriendlyId}`], + }); + const secondJwt = await generateTestJWT(environment, { + scopes: [`read:runs:${second.runFriendlyId}`], + }); + const asFirst = { headers: { Authorization: `Bearer ${firstJwt}` } }; + const asSecond = { headers: { Authorization: `Bearer ${secondJwt}` } }; + + const ownPayload = await server.webapp.fetch( + `/api/v1/runs/${first.runFriendlyId}/packets/payload`, + asFirst + ); + const ownOutput = await server.webapp.fetch( + `/api/v1/runs/${first.runFriendlyId}/packets/output`, + asFirst + ); + const otherRun = await server.webapp.fetch( + `/api/v1/runs/${second.runFriendlyId}/packets/payload`, + asFirst + ); + const inlineOutput = await server.webapp.fetch( + `/api/v1/runs/${second.runFriendlyId}/packets/output`, + asSecond + ); + + expect(ownPayload.status).toBe(500); + expect(await ownPayload.json()).toEqual(objectStoreError); + expect(ownOutput.status).toBe(500); + expect(await ownOutput.json()).toEqual(objectStoreError); + expect(otherRun.status).toBe(403); + expect(inlineOutput.status).toBe(404); + }); + + it("rejects stored-payload pointers from public JWTs on trigger", async () => { + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateTestJWT(environment, { scopes: ["write:tasks:test-task"] }); + + const res = await server.webapp.fetch("/api/v1/tasks/test-task/trigger", { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + payload: "run_victim/payload.json", + options: { payloadType: "application/store" }, + }), + }); + + expect(res.status).toBe(403); + }); + + it("rejects stored-payload pointers from public JWTs on batch trigger", async () => { + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateTestJWT(environment, { scopes: ["write:tasks:test-task"] }); + + const res = await server.webapp.fetch("/api/v2/tasks/batch", { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + items: [ + { + task: "test-task", + payload: "run_victim/payload.json", + options: { payloadType: "application/store" }, + }, + ], + }), + }); + + expect(res.status).toBe(403); + }); +}); + describe("JWT bearer auth — baseline behavior", () => { it("valid JWT on JWT-enabled route: auth passes", async () => { const { environment } = await seedTestEnvironment(server.prisma); diff --git a/apps/webapp/test/auth-jwt-mint.e2e.full.test.ts b/apps/webapp/test/auth-jwt-mint.e2e.full.test.ts new file mode 100644 index 00000000000..81d060d95c5 --- /dev/null +++ b/apps/webapp/test/auth-jwt-mint.e2e.full.test.ts @@ -0,0 +1,125 @@ +// POST /api/v1/auth/jwt hardening — the route spreads caller-supplied claims +// into a public JWT. These tests lock in that: +// 1. caller `expirationTime` is capped (an uncapped "100y" mints a token no +// key rotation can revoke, since public JWTs outlive key rotation); +// 2. a revoked (grace-window) key can no longer mint — those tokens are +// signed with the replacement key, so they kept validating after +// rotation, defeating revocation; +// 3. a valid root key still mints, with the requested scopes echoed back. +// +// Scope *inflation* for a restricted additional key is covered by the +// scope-grammar unit tests (internal-packages/rbac ability.test.ts) plus the +// route's `scopesWithinAbility` clamp; the shared e2e server runs with the +// additional-key lookup flag off, so an additional-key mint would 401 here for +// an unrelated reason. + +import { describe, expect, it } from "vitest"; +import { getTestServer } from "./helpers/sharedTestServer"; +import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; + +const ROUTE = "/api/v1/auth/jwt"; + +function mint(apiKey: string, body: unknown) { + const server = getTestServer(); + return server.webapp.fetch(ROUTE, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +function decodePayload(token: string): Record { + const part = token.split(".")[1]!; + const base64 = part.replace(/-/g, "+").replace(/_/g, "/"); + return JSON.parse(Buffer.from(base64, "base64").toString("utf8")); +} + +describe("POST /api/v1/auth/jwt", () => { + it("mints a token for a valid root key, echoing the requested scopes", async () => { + const server = getTestServer(); + const { apiKey, environment } = await seedTestEnvironment(server.prisma); + + const res = await mint(apiKey, { + claims: { scopes: ["read:runs"] }, + expirationTime: "1h", + }); + expect(res.status).toBe(200); + + const { token } = (await res.json()) as { token: string }; + const payload = decodePayload(token); + expect(payload.sub).toBe(environment.id); + expect(payload.pub).toBe(true); + expect(payload.scopes).toEqual(["read:runs"]); + + // exp must be roughly now + 1h, well under the 24h cap. + const nowSec = Math.floor(Date.now() / 1000); + const exp = payload.exp as number; + expect(exp).toBeGreaterThan(nowSec); + expect(exp).toBeLessThanOrEqual(nowSec + 60 * 60 + 60); + }); + + it("rejects an uncapped expirationTime (100y) with 400", async () => { + const server = getTestServer(); + const { apiKey } = await seedTestEnvironment(server.prisma); + + const res = await mint(apiKey, { + claims: { scopes: ["read:runs"] }, + expirationTime: "100y", + }); + expect(res.status).toBe(400); + }); + + it("rejects an expirationTime beyond 24h with 400", async () => { + const server = getTestServer(); + const { apiKey } = await seedTestEnvironment(server.prisma); + + const res = await mint(apiKey, { expirationTime: "48h" }); + expect(res.status).toBe(400); + }); + + it("rejects a past expirationTime with 400", async () => { + const server = getTestServer(); + const { apiKey } = await seedTestEnvironment(server.prisma); + + // Absolute epoch timestamp in the past. + const past = Math.floor(Date.now() / 1000) - 60; + const res = await mint(apiKey, { expirationTime: past }); + expect(res.status).toBe(400); + }); + + it("refuses to mint from a revoked (grace-window) key with 401", async () => { + const server = getTestServer(); + const { apiKey, environment } = await seedTestEnvironment(server.prisma); + + // Sanity: the live key mints. + const before = await mint(apiKey, { expirationTime: "1h" }); + expect(before.status).toBe(200); + + // Rotate exactly as regenerate-api-key does: new value on the env, old + // value parked in RevokedApiKey with a future grace expiry. The old key + // still authenticates elsewhere (grace window, by design) but must not + // mint new JWTs. + await server.prisma.$transaction([ + server.prisma.revokedApiKey.create({ + data: { + apiKey, + runtimeEnvironmentId: environment.id, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // +1 day + }, + }), + server.prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { apiKey: `tr_dev_rotated_${Math.random().toString(36).slice(2)}` }, + }), + ]); + + const res = await mint(apiKey, { + claims: { scopes: ["read:runs"] }, + expirationTime: "1h", + }); + expect(res.status).toBe(401); + }); +}); diff --git a/apps/webapp/test/githubSettingsRedirect.test.ts b/apps/webapp/test/githubSettingsRedirect.test.ts new file mode 100644 index 00000000000..4ff5a77b4eb --- /dev/null +++ b/apps/webapp/test/githubSettingsRedirect.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeGitHubSettingsRedirect } from "~/v3/github/githubSettingsRedirect.server"; + +const APPLICATION_ORIGIN = "https://dashboard.example.com"; + +describe("sanitizeGitHubSettingsRedirect", () => { + it.each([ + "/orgs/example/projects/demo/env/prod/settings/integrations", + "/orgs/example/projects/demo/env/prod/settings/integrations?origin=marketplace&next=value#github", + ])("preserves a navigable relative dashboard URL", (redirectUrl) => { + expect(sanitizeGitHubSettingsRedirect(redirectUrl, APPLICATION_ORIGIN)).toBe(redirectUrl); + }); + + it("converts a same-origin absolute URL to a relative dashboard URL", () => { + expect( + sanitizeGitHubSettingsRedirect( + "https://dashboard.example.com/orgs/example/settings?tab=integrations#github", + APPLICATION_ORIGIN + ) + ).toBe("/orgs/example/settings?tab=integrations#github"); + }); + + it.each([ + "https://vercel.com/integrations/example/new?configurationId=123", + "https://marketplace.vercel.com/integrations/example#complete", + ])("preserves a credential-free HTTPS Vercel URL", (redirectUrl) => { + expect(sanitizeGitHubSettingsRedirect(redirectUrl, APPLICATION_ORIGIN)).toBe(redirectUrl); + }); + + it.each([ + "https://example.com/steal", + "https://vercel.com.example.com/steal", + "https://evilvercel.com/steal", + "http://vercel.com/steal", + "https://user:password@vercel.com/steal", + "https://vercel.com:8443/steal", + "//vercel.com/steal", + "/\\vercel.com/steal", + "/resources/private", + "not a URL", + ])("rejects an unsafe return destination: %s", (redirectUrl) => { + expect(sanitizeGitHubSettingsRedirect(redirectUrl, APPLICATION_ORIGIN)).toBeUndefined(); + }); + + it("does not trust an origin or marketplace marker in the query", () => { + expect( + sanitizeGitHubSettingsRedirect( + "/orgs/example/settings?origin=marketplace&next=https%3A%2F%2Fevil.example", + APPLICATION_ORIGIN + ) + ).toBe("/orgs/example/settings?origin=marketplace&next=https%3A%2F%2Fevil.example"); + }); +}); diff --git a/apps/webapp/test/platformNotifications.test.ts b/apps/webapp/test/platformNotifications.test.ts index d8c3ad20ce5..2c09211e02e 100644 --- a/apps/webapp/test/platformNotifications.test.ts +++ b/apps/webapp/test/platformNotifications.test.ts @@ -3,15 +3,22 @@ import { type Prisma, type PrismaClient } from "@trigger.dev/database"; import { describe, expect, it, vi } from "vitest"; import { createDraftPlatformNotification, + dismissNotification, getActivePlatformNotifications, getAdminNotificationsList, getNextCliNotification, getRecentChangelogs, publishDraftPlatformNotification, + recordNotificationClicked, + recordNotificationSeen, updateDraftPlatformNotification, } from "~/services/platformNotifications.server"; import { CreatePlatformNotificationSchema } from "~/services/platformNotificationSchemas"; import { isCliVersionEligible } from "~/services/platformNotificationVersionTargeting"; +import { + createTestOrgProjectWithMember, + createTestUser, +} from "./fixtures/environmentVariablesFixtures"; // Container provisioning on the first draft tests can exceed the 5s default. vi.setConfig({ testTimeout: 60_000 }); @@ -163,6 +170,104 @@ async function seedNotification( }); } +describe("platform notification interaction access", () => { + postgresTest( + "records interactions only for notifications visible to the stored scope", + async ({ prisma }) => { + const owner = await createTestOrgProjectWithMember(prisma); + const other = await createTestOrgProjectWithMember(prisma); + const otherUser = await createTestUser(prisma); + const now = new Date(); + const base = { + title: `admin_${suffix()}`, + payload: webappCardPayload("visible"), + surface: "WEBAPP" as const, + startsAt: new Date(now.getTime() - HOUR_MS), + endsAt: new Date(now.getTime() + HOUR_MS), + }; + + const global = await prisma.platformNotification.create({ + data: { ...base, scope: "GLOBAL" }, + }); + const user = await prisma.platformNotification.create({ + data: { ...base, scope: "USER", userId: owner.user.id }, + }); + const organization = await prisma.platformNotification.create({ + data: { ...base, scope: "ORGANIZATION", organizationId: owner.organization.id }, + }); + const project = await prisma.platformNotification.create({ + data: { ...base, scope: "PROJECT", projectId: owner.project.id }, + }); + + const foreignProject = await prisma.platformNotification.create({ + data: { ...base, scope: "PROJECT", projectId: other.project.id }, + }); + const foreignUser = await prisma.platformNotification.create({ + data: { ...base, scope: "USER", userId: otherUser.id }, + }); + const draft = await prisma.platformNotification.create({ + data: { ...base, scope: "GLOBAL", isDraft: true }, + }); + const future = await prisma.platformNotification.create({ + data: { ...base, scope: "GLOBAL", startsAt: new Date(now.getTime() + HOUR_MS) }, + }); + + const ownerId = owner.user.id; + expect( + await recordNotificationSeen({ notificationId: global.id, userId: ownerId }, prisma) + ).toBe(true); + expect(await dismissNotification({ notificationId: user.id, userId: ownerId }, prisma)).toBe( + true + ); + expect( + await recordNotificationClicked( + { notificationId: organization.id, userId: ownerId }, + prisma + ) + ).toBe(true); + expect( + await recordNotificationSeen({ notificationId: project.id, userId: ownerId }, prisma) + ).toBe(true); + + for (const denied of [foreignProject, foreignUser, draft, future]) { + expect( + await recordNotificationSeen({ notificationId: denied.id, userId: ownerId }, prisma) + ).toBe(false); + } + + expect( + await prisma.platformNotificationInteraction.count({ where: { userId: ownerId } }) + ).toBe(4); + } + ); + + postgresTest( + "allows late interactions with an authorized historical changelog", + async ({ prisma }) => { + const owner = await createTestOrgProjectWithMember(prisma); + const notification = await prisma.platformNotification.create({ + data: { + title: `admin_${suffix()}`, + payload: changelogPayload("historical"), + surface: "WEBAPP", + scope: "PROJECT", + projectId: owner.project.id, + startsAt: new Date(Date.now() - 2 * HOUR_MS), + endsAt: new Date(Date.now() - HOUR_MS), + archivedAt: new Date(), + }, + }); + + expect( + await recordNotificationClicked( + { notificationId: notification.id, userId: owner.user.id }, + prisma + ) + ).toBe(true); + } + ); +}); + describe("platform notification drafts are hidden from users", () => { postgresTest("getActivePlatformNotifications excludes drafts", async ({ prisma }) => { const published = await seedNotification(prisma, { diff --git a/apps/webapp/test/publicTokensRoute.test.ts b/apps/webapp/test/publicTokensRoute.test.ts index 6a8a5a54f31..94831f61dbb 100644 --- a/apps/webapp/test/publicTokensRoute.test.ts +++ b/apps/webapp/test/publicTokensRoute.test.ts @@ -74,13 +74,17 @@ function telemetryRecorder(): ApiKeyTelemetry { describe("POST /api/v1/auth/public-tokens", () => { it("lets root and unrestricted additional keys mint arbitrary scopes", async () => { + const graceWindowLookup = vi.fn().mockResolvedValue(false); + for (const controller of [ controllerWithAbility(permissiveAbility, "root"), controllerWithAbility(permissiveAbility, "additional"), ]) { const response = await handlePublicTokenRequest( request({ scopes: ["read:runs", "custom:resources:value"] }), - controller + controller, + telemetryRecorder(), + graceWindowLookup ); expect(response.status).toBe(200); @@ -94,6 +98,40 @@ describe("POST /api/v1/auth/public-tokens", () => { scopes: ["read:runs", "custom:resources:value"], }); } + + expect(graceWindowLookup).toHaveBeenCalledTimes(1); + expect(graceWindowLookup).toHaveBeenCalledWith("tr_prod_test"); + }); + + it("rejects root keys in their rotation grace window", async () => { + const telemetry = telemetryRecorder(); + const graceWindowLookup = vi.fn().mockResolvedValue(true); + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }), + controllerWithAbility(permissiveAbility, "root"), + telemetry, + graceWindowLookup + ); + + expect(response.status).toBe(401); + await expect(responseJson(response)).resolves.toEqual({ error: "Invalid API key" }); + expect(graceWindowLookup).toHaveBeenCalledTimes(1); + expect(graceWindowLookup).toHaveBeenCalledWith("tr_prod_test"); + expect(telemetry.recordPublicTokenMint).toHaveBeenCalledTimes(1); + expect(telemetry.recordPublicTokenMint).toHaveBeenCalledWith("rejected", "revoked_key"); + }); + + it("does not apply the root rotation check to additional keys", async () => { + const graceWindowLookup = vi.fn().mockResolvedValue(true); + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }), + controllerWithAbility(permissiveAbility, "additional"), + telemetryRecorder(), + graceWindowLookup + ); + + expect(response.status).toBe(200); + expect(graceWindowLookup).not.toHaveBeenCalled(); }); it("records successful and rejected mint outcomes", async () => { diff --git a/apps/webapp/test/regenerateApiKeyBranch.e2e.full.test.ts b/apps/webapp/test/regenerateApiKeyBranch.e2e.full.test.ts new file mode 100644 index 00000000000..bf3786d7db8 --- /dev/null +++ b/apps/webapp/test/regenerateApiKeyBranch.e2e.full.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { getTestServer } from "./helpers/sharedTestServer"; +import { seedTestUserProject } from "./helpers/seedTestUserProject"; + +describe("regenerate API key branch handling", () => { + it.each(["feature", "", "default"])( + "rejects a present branch header without rotating the root key (%s)", + async (branch) => { + const server = getTestServer(); + const { environment, pat, project } = await seedTestUserProject(server.prisma); + + const response = await server.webapp.fetch( + `/api/v1/projects/${project.externalRef}/dev/regenerate-api-key`, + { + method: "POST", + headers: { + Authorization: `Bearer ${pat.token}`, + "x-trigger-branch": branch, + }, + } + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "Branch-scoped key rotation is not supported", + }); + const persisted = await server.prisma.runtimeEnvironment.findUniqueOrThrow({ + where: { id: environment.id }, + select: { apiKey: true }, + }); + expect(persisted.apiKey).toBe(environment.apiKey); + } + ); + + it("preserves root key rotation and grace behavior without a branch header", async () => { + const server = getTestServer(); + const { environment, pat, project } = await seedTestUserProject(server.prisma); + + const response = await server.webapp.fetch( + `/api/v1/projects/${project.externalRef}/dev/regenerate-api-key`, + { + method: "POST", + headers: { Authorization: `Bearer ${pat.token}` }, + } + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { apiKey: string }; + expect(body.apiKey).not.toBe(environment.apiKey); + + const revoked = await server.prisma.revokedApiKey.findFirst({ + where: { apiKey: environment.apiKey, runtimeEnvironmentId: environment.id }, + }); + expect(revoked?.expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); +}); diff --git a/apps/webapp/test/sanitizeHttpUrl.test.ts b/apps/webapp/test/sanitizeHttpUrl.test.ts index cf24c284db1..715525cfc22 100644 --- a/apps/webapp/test/sanitizeHttpUrl.test.ts +++ b/apps/webapp/test/sanitizeHttpUrl.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sanitizeHttpUrl } from "../app/utils/sanitizeHttpUrl.js"; +import { getRouterPath, pathHasPrefix, sanitizeHttpUrl } from "../app/utils/sanitizeHttpUrl.js"; describe("sanitizeHttpUrl", () => { it.each([ @@ -18,3 +18,25 @@ describe("sanitizeHttpUrl", () => { expect(sanitizeHttpUrl(value)).toBe(expected); }); }); + +describe("getRouterPath", () => { + it.each([ + ["/realtime/v1/runs?cursor=1", "/realtime/v1/runs"], + ["/realtime/../api/v1/tasks", "/api/v1/tasks"], + ["/realtime/%2e%2e/api/v1/tasks", "/api/v1/tasks"], + ["/ws?token=value", "/ws"], + ["/socket.io/?transport=websocket", "/socket.io/"], + ])("normalizes %s like the application router", (url, expected) => { + expect(getRouterPath({ url })).toBe(expected); + }); + + it("rejects malformed absolute request targets", () => { + expect(getRouterPath({ url: "http://[" })).toBeUndefined(); + }); + + it("matches complete path segments only", () => { + expect(pathHasPrefix("/realtime", "/realtime")).toBe(true); + expect(pathHasPrefix("/realtime/v1/runs", "/realtime")).toBe(true); + expect(pathHasPrefix("/realtime-admin", "/realtime")).toBe(false); + }); +}); diff --git a/apps/webapp/test/sessionSnapshotUrlAuth.e2e.test.ts b/apps/webapp/test/sessionSnapshotUrlAuth.e2e.test.ts new file mode 100644 index 00000000000..85a8984fce4 --- /dev/null +++ b/apps/webapp/test/sessionSnapshotUrlAuth.e2e.test.ts @@ -0,0 +1,66 @@ +import { generateJWT } from "@trigger.dev/core/v3/jwt"; +import type { TestServer } from "@internal/testcontainers/webapp"; +import { startTestServer } from "@internal/testcontainers/webapp"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { seedTestApiSession } from "./helpers/seedTestApiSession"; +import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; + +vi.setConfig({ testTimeout: 180_000, hookTimeout: 240_000 }); + +let server: TestServer; + +beforeAll(async () => { + server = await startTestServer({ + extraEnv: { + OBJECT_STORE_BASE_URL: "http://object-store.example.test", + OBJECT_STORE_ACCESS_KEY_ID: "test-access-key", + OBJECT_STORE_SECRET_ACCESS_KEY: "test-secret-key", + }, + }); +}, 240_000); + +afterAll(async () => { + await server?.stop(); +}, 120_000); + +async function setupSession() { + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const publicToken = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`read:sessions:${session.friendlyId}`, `write:sessions:${session.friendlyId}`], + }, + expirationTime: "15m", + }); + + return { ...seed, session, publicToken }; +} + +function requestSnapshotUrl(sessionId: string, method: "GET" | "PUT", token: string) { + return server.webapp.fetch(`/api/v1/sessions/${sessionId}/snapshot-url`, { + method, + headers: { Authorization: `Bearer ${token}` }, + }); +} + +describe("session snapshot URL auth", () => { + it("denies a presigned PUT to a public session token", async () => { + const { session, publicToken } = await setupSession(); + + const response = await requestSnapshotUrl(session.friendlyId, "PUT", publicToken); + + expect(response.status).toBe(401); + }); + + it("returns a presigned PUT to the private runtime API key", async () => { + const { apiKey, session } = await setupSession(); + + const response = await requestSnapshotUrl(session.friendlyId, "PUT", apiKey); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ presignedUrl: expect.any(String) }); + }); +}); diff --git a/apps/webapp/test/streamBatchItemsAuthorization.test.ts b/apps/webapp/test/streamBatchItemsAuthorization.test.ts index 0ea800a1049..6d670f0aeb6 100644 --- a/apps/webapp/test/streamBatchItemsAuthorization.test.ts +++ b/apps/webapp/test/streamBatchItemsAuthorization.test.ts @@ -45,6 +45,21 @@ describe("streaming batch item authorization", () => { ).resolves.toHaveLength(2); }); + it("rejects stored packet pointers from delegated batch credentials", async () => { + const ability = withActionAliases(buildJwtAbility(["write:batch:batch_123"])); + async function* storedPayloadItem() { + yield { + task: "task-a", + payload: "run_victim/payload.json", + options: { payloadType: "application/store" }, + }; + } + + await expect( + collect(authorizeBatchItems(storedPayloadItem(), ability, "batch_123", false)) + ).rejects.toThrow(); + }); + it("does not delegate batch-wide writes from selected-task credentials", () => { const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); diff --git a/apps/webapp/test/tenantAccess.test.ts b/apps/webapp/test/tenantAccess.test.ts new file mode 100644 index 00000000000..95ec7ed9b8d --- /dev/null +++ b/apps/webapp/test/tenantAccess.test.ts @@ -0,0 +1,148 @@ +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect, vi } from "vitest"; +import { + findProjectByRef, + findProjectBySlug, + findProjectWithOrgFlagsBySlug, +} from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server"; +import { + createRuntimeEnvironment, + createTestOrgProjectWithMember, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; + +vi.setConfig({ testTimeout: 60_000 }); + +async function createSchedule(prisma: PrismaClient, projectId: string, environmentIds: string[]) { + return prisma.taskSchedule.create({ + data: { + friendlyId: uniqueId("schedule"), + taskIdentifier: "scheduled-task", + projectId, + generatorExpression: "0 * * * *", + type: "IMPERATIVE", + instances: { + create: environmentIds.map((environmentId) => ({ environmentId, projectId })), + }, + }, + }); +} + +describe("tenant access boundaries", () => { + postgresTest( + "excludes deleted projects and organizations without hiding archived environments", + async ({ prisma }) => { + const { user, organization, project } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + slug: "prod", + }); + + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { archivedAt: new Date() }, + }); + + expect( + await findProjectBySlug(organization.slug, project.slug, user.id, prisma) + ).not.toBeNull(); + expect(await findProjectByRef(project.externalRef, user.id, prisma)).not.toBeNull(); + expect( + await findProjectWithOrgFlagsBySlug(organization.slug, project.slug, user.id, prisma) + ).not.toBeNull(); + expect( + await findEnvironmentBySlug(project.id, environment.slug, user.id, prisma) + ).not.toBeNull(); + + await prisma.project.update({ where: { id: project.id }, data: { deletedAt: new Date() } }); + + expect(await findProjectBySlug(organization.slug, project.slug, user.id, prisma)).toBeNull(); + expect(await findProjectByRef(project.externalRef, user.id, prisma)).toBeNull(); + expect( + await findProjectWithOrgFlagsBySlug(organization.slug, project.slug, user.id, prisma) + ).toBeNull(); + expect(await findEnvironmentBySlug(project.id, environment.slug, user.id, prisma)).toBeNull(); + + await prisma.project.update({ where: { id: project.id }, data: { deletedAt: null } }); + await prisma.organization.update({ + where: { id: organization.id }, + data: { deletedAt: new Date() }, + }); + + expect(await findProjectBySlug(organization.slug, project.slug, user.id, prisma)).toBeNull(); + expect(await findProjectByRef(project.externalRef, user.id, prisma)).toBeNull(); + expect( + await findProjectWithOrgFlagsBySlug(organization.slug, project.slug, user.id, prisma) + ).toBeNull(); + expect(await findEnvironmentBySlug(project.id, environment.slug, user.id, prisma)).toBeNull(); + } + ); + + postgresTest( + "rejects schedules from another project while preserving new and cross-environment schedules", + async ({ prisma }) => { + const owner = await createTestOrgProjectWithMember(prisma); + const production = await createRuntimeEnvironment(prisma, { + projectId: owner.project.id, + organizationId: owner.organization.id, + type: "PRODUCTION", + slug: "prod", + }); + const staging = await createRuntimeEnvironment(prisma, { + projectId: owner.project.id, + organizationId: owner.organization.id, + type: "STAGING", + slug: "staging", + }); + const ownSchedule = await createSchedule(prisma, owner.project.id, [ + production.id, + staging.id, + ]); + + const other = await createTestOrgProjectWithMember(prisma); + const foreignSchedule = await createSchedule(prisma, other.project.id, []); + const presenter = new EditSchedulePresenter(prisma); + + const newSchedule = await presenter.call({ + userId: owner.user.id, + projectSlug: owner.project.slug, + environmentSlug: production.slug, + }); + expect(newSchedule.schedule).toBeUndefined(); + + const editable = await presenter.call({ + userId: owner.user.id, + projectSlug: owner.project.slug, + environmentSlug: production.slug, + friendlyId: ownSchedule.friendlyId, + }); + expect(editable.schedule?.environments.map((environment) => environment.id).sort()).toEqual( + [production.id, staging.id].sort() + ); + expect(editable.schedule).not.toHaveProperty("instances"); + + await expect( + presenter.call({ + userId: owner.user.id, + projectSlug: owner.project.slug, + environmentSlug: production.slug, + friendlyId: foreignSchedule.friendlyId, + }) + ).rejects.toMatchObject({ status: 404 }); + + await expect( + presenter.call({ + userId: owner.user.id, + projectSlug: owner.project.slug, + environmentSlug: production.slug, + friendlyId: uniqueId("missing-schedule"), + }) + ).rejects.toMatchObject({ status: 404 }); + } + ); +}); diff --git a/apps/webapp/test/timeGranularity.test.ts b/apps/webapp/test/timeGranularity.test.ts index dbe41c37718..15aa98f01bf 100644 --- a/apps/webapp/test/timeGranularity.test.ts +++ b/apps/webapp/test/timeGranularity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { TimeGranularity } from "~/utils/timeGranularity"; +import { isBoundedChartRange, TimeGranularity } from "~/utils/timeGranularity"; const SECOND = 1_000; const MINUTE = 60 * SECOND; @@ -58,3 +58,23 @@ describe("TimeGranularity", () => { expect(() => new TimeGranularity([])).toThrow("at least one bracket"); }); }); + +describe("isBoundedChartRange", () => { + const day = 24 * 60 * 60 * 1000; + const now = Date.parse("2026-08-08T00:00:00Z"); + + it("accepts an ordinary window", () => { + expect(isBoundedChartRange(new Date(now - 7 * day), new Date(now), 2 * 60 * 60 * 1000)).toBe( + true + ); + }); + + it.each([ + ["NaN from", new Date(Number.NaN), new Date(now)], + ["inverted", new Date(now), new Date(now - 1000)], + ["extreme past", new Date(-8.64e15), new Date(now)], + ["extreme future", new Date(now), new Date(8.64e15)], + ])("rejects %s before allocation", (_label, from, to) => { + expect(isBoundedChartRange(from, to, 30 * day)).toBe(false); + }); +}); diff --git a/apps/webapp/test/vercelErrorLogMetadata.test.ts b/apps/webapp/test/vercelErrorLogMetadata.test.ts new file mode 100644 index 00000000000..746b306ed78 --- /dev/null +++ b/apps/webapp/test/vercelErrorLogMetadata.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { vercelErrorLogMetadata } from "~/v3/vercel/vercelErrorLogMetadata"; + +class ResponseValidationError extends Error { + rawValue = { envs: [{ key: "SECRET", value: "sensitive-value" }] }; + response = { status: 422, body: "sensitive-response" }; +} + +class UnexpectedProviderError extends Error { + statusCode = 503; + body = "sensitive-response"; +} + +describe("vercelErrorLogMetadata", () => { + it("returns only allowlisted metadata for a validation error", () => { + const metadata = vercelErrorLogMetadata( + new ResponseValidationError("response included sensitive-value") + ); + + expect(metadata).toEqual({ errorType: "ResponseValidationError", status: 422 }); + expect(Object.keys(metadata)).toEqual(["errorType", "status"]); + }); + + it("preserves allowlisted provider and transport error names", () => { + const forbidden = Object.assign(new Error("provider details"), { + name: "Forbidden", + statusCode: 403, + }); + const timeout = Object.assign(new Error("provider details"), { + name: "RequestTimeoutError", + }); + + expect(vercelErrorLogMetadata(forbidden)).toEqual({ errorType: "Forbidden", status: 403 }); + expect(vercelErrorLogMetadata(timeout)).toEqual({ errorType: "RequestTimeoutError" }); + }); + + it("normalizes unknown error names while preserving a valid status", () => { + expect(vercelErrorLogMetadata(new UnexpectedProviderError("provider details"))).toEqual({ + errorType: "Error", + status: 503, + }); + }); + + it.each([undefined, null, "failure", { status: 99 }, { status: 600 }])( + "does not include untrusted details from %j", + (error) => { + expect(vercelErrorLogMetadata(error)).toEqual({ errorType: "UnknownError" }); + } + ); +}); diff --git a/apps/webapp/test/vercelSdkRecovery.test.ts b/apps/webapp/test/vercelSdkRecovery.test.ts new file mode 100644 index 00000000000..14c130de1ab --- /dev/null +++ b/apps/webapp/test/vercelSdkRecovery.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const logger = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), +})); + +vi.mock("~/services/logger.server", () => ({ logger })); + +import { + callVercelWithRecovery, + VercelSchemas, + wrapVercelCallWithRecovery, +} from "~/models/vercelSdkRecovery.server"; +import { toVercelApiError, VERCEL_API_ERROR_MESSAGE } from "~/v3/vercel/vercelApiError"; + +const SENTINEL_SECRET = "provider-sentinel-secret"; + +class ResponseValidationError extends Error { + rawValue: unknown; + response: { status: number; body: string }; + + constructor(rawValue: unknown, status: number) { + super(`Provider response included ${SENTINEL_SECRET}`); + this.rawValue = rawValue; + this.response = { status, body: `body:${SENTINEL_SECRET}` }; + } +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("Vercel SDK recovery diagnostics", () => { + it("replaces a recovery failure before error conversion and downstream logging", async () => { + const result = await callVercelWithRecovery( + Promise.reject(new ResponseValidationError({ token: SENTINEL_SECRET }, 403)), + VercelSchemas.getTeam, + { context: "validateVercelToken" } + ); + + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + + expect(result.error).toEqual({ + message: VERCEL_API_ERROR_MESSAGE, + authInvalid: true, + errorType: "ResponseValidationError", + status: 403, + }); + + const apiError = toVercelApiError(result.error); + const downstreamDiagnostic = { + error: apiError.message, + authInvalid: apiError.authInvalid, + }; + + expect(downstreamDiagnostic).toEqual({ + error: VERCEL_API_ERROR_MESSAGE, + authInvalid: true, + }); + expect(JSON.stringify({ replacement: result.error, downstreamDiagnostic })).not.toContain( + SENTINEL_SECRET + ); + }); + + it("returns and logs only fixed diagnostics from the repository wrapper", async () => { + const result = await wrapVercelCallWithRecovery( + Promise.reject(new ResponseValidationError({ token: SENTINEL_SECRET }, 422)), + VercelSchemas.getTeam, + "Failed to fetch Vercel team", + { teamId: "team_123" }, + toVercelApiError + ); + + expect(result.isErr()).toBe(true); + if (result.isOk()) return; + + expect(result.error).toEqual({ + message: "Failed to fetch Vercel team", + authInvalid: false, + }); + expect(logger.error).toHaveBeenCalledWith("Failed to fetch Vercel team", { + teamId: "team_123", + errorType: "ResponseValidationError", + status: 422, + authInvalid: false, + }); + + logger.warn("Representative downstream Vercel failure", { + error: result.error.message, + authInvalid: result.error.authInvalid, + }); + + expect(logger.warn).toHaveBeenCalledWith("Representative downstream Vercel failure", { + error: "Failed to fetch Vercel team", + authInvalid: false, + }); + expect(JSON.stringify({ error: result.error, calls: logger })).not.toContain(SENTINEL_SECRET); + }); + + it("retains rawValue-derived functional data after successful recovery", async () => { + const result = await callVercelWithRecovery( + Promise.reject( + new ResponseValidationError( + { key: "API_TOKEN", value: SENTINEL_SECRET, providerField: "retained" }, + 422 + ) + ), + VercelSchemas.getProjectEnv, + { context: "resolveEnvVarValue" } + ); + + expect(result.isOk()).toBe(true); + if (result.isErr()) return; + + expect(result.value).toEqual({ + key: "API_TOKEN", + value: SENTINEL_SECRET, + providerField: "retained", + }); + expect(logger.warn).toHaveBeenCalledWith("Recovered data from Vercel SDK validation error", { + context: "resolveEnvVarValue", + errorType: "ResponseValidationError", + status: 422, + }); + expect(JSON.stringify(logger.warn.mock.calls)).not.toContain(SENTINEL_SECRET); + }); +}); diff --git a/apps/webapp/test/workerIdUnwrap.test.ts b/apps/webapp/test/workerIdUnwrap.test.ts index ea787a81108..99f6158700e 100644 --- a/apps/webapp/test/workerIdUnwrap.test.ts +++ b/apps/webapp/test/workerIdUnwrap.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { mintWorkloadDeploymentToken, SemanticInternalAttributes } from "@trigger.dev/core/v3"; +import type { ResourceMetrics } from "@trigger.dev/otlp-importer"; +import { convertMetricsToClickhouseRows } from "~/v3/otlpTransform.server"; import { unwrapWorkerId, unwrapWorkerIdInMetadata } from "~/v3/workerIdUnwrap.server"; const EXP = Math.floor(Date.UTC(2032, 0, 1) / 1000); @@ -44,6 +46,45 @@ describe("unwrapWorkerId", () => { }); }); +describe("metric worker IDs", () => { + it("stores the deployment friendly ID instead of a worker token", async () => { + const token = await mint("deployment_metric"); + const resourceMetrics = { + resource: { + attributes: [ + { + key: SemanticInternalAttributes.WORKER_ID, + value: { stringValue: token }, + }, + ], + }, + scopeMetrics: [ + { + metrics: [ + { + name: "test.metric", + gauge: { + dataPoints: [ + { + asDouble: 1, + timeUnixNano: 0n, + }, + ], + }, + }, + ], + }, + ], + } as unknown as ResourceMetrics; + + const [row] = convertMetricsToClickhouseRows(resourceMetrics, 1024); + + expect(row?.attributes).toEqual({ + trigger: { worker_id: "deployment_metric" }, + }); + }); +}); + describe("unwrapWorkerIdInMetadata", () => { it("unwraps a token worker.id in the metadata bag, leaving other keys untouched", async () => { const metadata = { diff --git a/hosting/docker/generate-secrets.sh b/hosting/docker/generate-secrets.sh index 679dc129cfb..8a21a82e2e2 100755 --- a/hosting/docker/generate-secrets.sh +++ b/hosting/docker/generate-secrets.sh @@ -6,6 +6,7 @@ # everyone out, or break an already-initialised datastore volume - so rotation is # opt-in only, via --force (which WILL break existing data/sessions). set -eu +umask 077 FORCE=0 for arg in "$@"; do @@ -35,6 +36,11 @@ if [ ! -f "$env_file" ]; then cp "$env_example" "$env_file" echo "Created $(basename "$env_file") from $(basename "$env_example")" fi +chmod 600 "$env_file" + +if [ -f "$htpasswd_file" ]; then + chmod 600 "$htpasswd_file" +fi sed_inplace() { if [ "$(uname)" = "Darwin" ]; then @@ -97,7 +103,9 @@ if [ "$FORCE" -eq 1 ] || [ -z "$(current_value DOCKER_REGISTRY_PASSWORD)" ]; the echo "ERROR: docker is required to hash the registry password (bcrypt). Install docker and re-run." >&2 exit 1 fi - docker run --rm httpd:2 htpasswd -Bbn "$registry_user" "$registry_pass" >"$htpasswd_file" + printf '%s\n' "$registry_pass" \ + | docker run --rm -i httpd:2 htpasswd -Bni "$registry_user" >"$htpasswd_file" + chmod 600 "$htpasswd_file" set_var DOCKER_REGISTRY_PASSWORD "$registry_pass" echo "Generated DOCKER_REGISTRY_PASSWORD (and wrote $(basename "$htpasswd_file"))" generated=$((generated + 1)) diff --git a/hosting/k8s/helm/Chart.yaml b/hosting/k8s/helm/Chart.yaml index 368264c444d..20c1984cbb9 100644 --- a/hosting/k8s/helm/Chart.yaml +++ b/hosting/k8s/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: trigger description: The official Trigger.dev Helm chart type: application -version: 4.6.1 -appVersion: v4.6.1 +version: 4.6.2 +appVersion: v4.6.2 home: https://trigger.dev sources: - https://github.com/triggerdotdev/trigger.dev diff --git a/internal-packages/clickhouse/schema/046_drop_task_events_attributes.sql b/internal-packages/clickhouse/schema/046_drop_task_events_attributes.sql new file mode 100644 index 00000000000..350d9722817 --- /dev/null +++ b/internal-packages/clickhouse/schema/046_drop_task_events_attributes.sql @@ -0,0 +1,13 @@ +-- +goose Up + +-- Writers stopped populating attributes several releases ago. Apply once the +-- table's TTL has aged out the stored values, so nothing readable is lost. +-- Metadata-only. The parts are rewritten by normal merges. +ALTER TABLE trigger_dev.task_events_v2 + DROP COLUMN IF EXISTS attributes; + +-- +goose Down + +ALTER TABLE trigger_dev.task_events_v2 + ADD COLUMN IF NOT EXISTS attributes JSON CODEC(ZSTD(1)) + AFTER status; diff --git a/internal-packages/clickhouse/src/taskEvents.test.ts b/internal-packages/clickhouse/src/taskEvents.test.ts index 2c4af0b3333..0c407a7416f 100644 --- a/internal-packages/clickhouse/src/taskEvents.test.ts +++ b/internal-packages/clickhouse/src/taskEvents.test.ts @@ -39,17 +39,12 @@ function readAttributesText(ch: ClickHouse) { return ch.reader.query({ name: "read-task-event-attributes", query: `SELECT attributes_text, - toJSONString(attributes) AS attributes_json, toUInt8(inserted_at > toDateTime64('2020-01-01 00:00:00', 3)) AS has_inserted_at FROM trigger_dev.task_events_v2 WHERE environment_id = {environmentId: String} AND span_id = {spanId: String}`, params: z.object({ environmentId: z.string(), spanId: z.string() }), - schema: z.object({ - attributes_text: z.string(), - attributes_json: z.string(), - has_inserted_at: z.number(), - }), + schema: z.object({ attributes_text: z.string(), has_inserted_at: z.number() }), }); } @@ -93,24 +88,24 @@ describe("task events v2", () => { expect(rows).toEqual([ { attributes_text: '{"z":1,"a":"hello","nested":{"enabled":true}}', - // The JSON column is no longer written; only the text is stored. - attributes_json: "{}", has_inserted_at: 1, }, ]); } ); - clickhouseTest("attributes_text is a plain stored column", async ({ clickhouseContainer }) => { - const ch = new ClickHouse({ url: clickhouseContainer.getConnectionUrl(), name: "test" }); + clickhouseTest( + "attributes_text is the only attributes column", + async ({ clickhouseContainer }) => { + const ch = new ClickHouse({ url: clickhouseContainer.getConnectionUrl(), name: "test" }); - const [columnError, columns] = await readColumnKinds(ch)({}); - expect(columnError).toBeNull(); - expect(columns).toEqual([ - { name: "attributes", default_kind: "", default_expression: "" }, - { name: "attributes_text", default_kind: "", default_expression: "" }, - ]); - }); + const [columnError, columns] = await readColumnKinds(ch)({}); + expect(columnError).toBeNull(); + expect(columns).toEqual([ + { name: "attributes_text", default_kind: "", default_expression: "" }, + ]); + } + ); clickhouseTest("has no attributes text indexes", async ({ clickhouseContainer }) => { const ch = new ClickHouse({ url: clickhouseContainer.getConnectionUrl(), name: "test" }); diff --git a/internal-packages/clickhouse/src/taskEvents.ts b/internal-packages/clickhouse/src/taskEvents.ts index 167e1b539ff..7892abfaf71 100644 --- a/internal-packages/clickhouse/src/taskEvents.ts +++ b/internal-packages/clickhouse/src/taskEvents.ts @@ -258,11 +258,7 @@ export function insertTaskEventsV2( table: "trigger_dev.task_events_v2", columns: TASK_EVENT_V2_INSERT_COLUMNS, settings: { - enable_json_type: 1, - type_json_skip_duplicated_paths: 1, - input_format_json_infer_array_of_dynamic_from_array_of_different_types: 1, input_format_json_throw_on_bad_escape_sequence: 0, - input_format_json_use_string_type_for_ambiguous_paths_in_named_tuples_inference_from_objects: 1, ...settings, }, }); diff --git a/internal-packages/llm-model-catalog/src/registry.ts b/internal-packages/llm-model-catalog/src/registry.ts index 3164efdc5e8..9ea13912749 100644 --- a/internal-packages/llm-model-catalog/src/registry.ts +++ b/internal-packages/llm-model-catalog/src/registry.ts @@ -11,6 +11,9 @@ type CompiledPattern = { model: LlmModelWithPricing; }; +const EXACT_MATCH_CACHE_MAX_ENTRIES = 1_000; +const EXACT_MATCH_CACHE_MAX_KEY_LENGTH = 512; + // Convert POSIX-style (?i) inline flag to JS RegExp 'i' flag function compilePattern(pattern: string): RegExp { if (pattern.startsWith("(?i)")) { @@ -22,9 +25,6 @@ function compilePattern(pattern: string): RegExp { export class ModelPricingRegistry { private _prisma?: PrismaClient | PrismaReplicaClient; private _patterns: CompiledPattern[] = []; - // TODO: When we add project-based models (users adding their own), this cache grows unbounded - // between reloads. Fine-tuned model IDs (e.g. "ft:gpt-3.5-turbo:org:name:id") create unique - // entries per model string. Consider adding an LRU cap or size limit at that point. private _exactMatchCache: Map = new Map(); private _loaded = false; private _readyResolve!: () => void; @@ -133,14 +133,13 @@ export class ModelPricingRegistry { match(responseModel: string): LlmModelWithPricing | null { if (!this._loaded) return null; - // Check exact match cache - const cached = this._exactMatchCache.get(responseModel); + const cached = this.getCachedMatch(responseModel); if (cached !== undefined) return cached; // Iterate compiled regex patterns for (const { regex, model } of this._patterns) { if (regex.test(responseModel)) { - this._exactMatchCache.set(responseModel, model); + this.cacheMatch(responseModel, model); return model; } } @@ -151,17 +150,40 @@ export class ModelPricingRegistry { const stripped = responseModel.split("/").slice(1).join("/"); for (const { regex, model } of this._patterns) { if (regex.test(stripped)) { - this._exactMatchCache.set(responseModel, model); + this.cacheMatch(responseModel, model); return model; } } } - // Cache miss - this._exactMatchCache.set(responseModel, null); + this.cacheMatch(responseModel, null); return null; } + private getCachedMatch(responseModel: string): LlmModelWithPricing | null | undefined { + if (responseModel.length > EXACT_MATCH_CACHE_MAX_KEY_LENGTH) return undefined; + + const cached = this._exactMatchCache.get(responseModel); + if (cached === undefined) return undefined; + + this._exactMatchCache.delete(responseModel); + this._exactMatchCache.set(responseModel, cached); + return cached; + } + + private cacheMatch(responseModel: string, model: LlmModelWithPricing | null): void { + if (responseModel.length > EXACT_MATCH_CACHE_MAX_KEY_LENGTH) return; + + if (this._exactMatchCache.has(responseModel)) { + this._exactMatchCache.delete(responseModel); + } else if (this._exactMatchCache.size >= EXACT_MATCH_CACHE_MAX_ENTRIES) { + const oldest = this._exactMatchCache.keys().next().value; + if (oldest !== undefined) this._exactMatchCache.delete(oldest); + } + + this._exactMatchCache.set(responseModel, model); + } + calculateCost(responseModel: string, usageDetails: Record): LlmCostResult | null { const model = this.match(responseModel); if (!model) return null; diff --git a/internal-packages/rbac/src/ability.test.ts b/internal-packages/rbac/src/ability.test.ts index cd784e25072..872abb7eba8 100644 --- a/internal-packages/rbac/src/ability.test.ts +++ b/internal-packages/rbac/src/ability.test.ts @@ -1,3 +1,4 @@ +import { buildScope } from "@trigger.dev/plugins"; import { describe, it, expect } from "vitest"; import { permissiveAbility, @@ -60,17 +61,42 @@ describe("buildJwtAbility", () => { expect(ability.can("read", { type: "runs" })).toBe(false); }); + it.each(["read:runs:", "read:runs: "])( + "rejects an explicitly empty resource ID in %s", + (scope) => { + const ability = buildJwtAbility([scope]); + expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(false); + expect(ability.can("read", { type: "runs" })).toBe(false); + } + ); + it("preserves colons in the resource id (everything after the 2nd colon)", () => { - // Resource ids can contain colons (e.g. user-provided tags like - // `env:staging`). The naive `[a, b, c] = scope.split(":")` form - // truncated `read:tags:env:staging` → scopeId="env" and silently - // mis-matched. Regression coverage for the multi-colon id path. const ability = buildJwtAbility(["read:tags:env:staging"]); expect(ability.can("read", { type: "tags", id: "env:staging" })).toBe(true); expect(ability.can("read", { type: "tags", id: "env" })).toBe(false); expect(ability.can("read", { type: "tags", id: "env:prod" })).toBe(false); }); + it("preserves meaningful whitespace around resource ids", () => { + const ability = buildJwtAbility(["read:tags: env:staging "]); + expect(ability.can("read", { type: "tags", id: " env:staging " })).toBe(true); + expect(ability.can("read", { type: "tags", id: "env:staging" })).toBe(false); + }); + + it("preserves wildcard actions and ignores resource condition fields", () => { + const ability = buildJwtAbility(["*:runs:run_abc"]); + const resource = { + type: "runs", + id: "run_abc", + projectId: "proj_123", + environmentId: "env_123", + }; + + expect(ability.can("read", resource)).toBe(true); + expect(ability.can("write", resource)).toBe(true); + expect(ability.can("read", { ...resource, id: "run_other" })).toBe(false); + }); + it("allows any read with read:all scope", () => { const ability = buildJwtAbility(["read:all"]); expect(ability.can("read", { type: "runs" })).toBe(true); @@ -122,6 +148,32 @@ describe("buildJwtAbility", () => { const ability = buildJwtAbility(["read:runs"]); expect(ability.can("write", { type: "runs" })).toBe(false); }); + + it("does not revisit the supplied scope set during repeated checks", () => { + const rawScopes = Array.from({ length: 300 }, (_, index) => `read:runs:allowed_${index}`); + let scopeReads = 0; + const scopes = new Proxy(rawScopes, { + get(target, property, receiver) { + if (typeof property === "string" && /^\d+$/.test(property)) { + scopeReads++; + } + return Reflect.get(target, property, receiver); + }, + }); + const ability = buildJwtAbility(scopes); + const readsAfterConstruction = scopeReads; + const childAliases = Array.from({ length: 13 }, (_, index) => ({ + type: "runs", + id: `denied_${index}`, + })); + + for (let child = 0; child < 50; child++) { + expect(ability.can("read", childAliases)).toBe(false); + } + + expect(readsAfterConstruction).toBeGreaterThan(0); + expect(scopeReads).toBe(readsAfterConstruction); + }); }); describe("scopesWithinAbility", () => { @@ -189,13 +241,27 @@ describe("scopesWithinAbility", () => { }); it("rejects malformed scopes for restricted abilities", () => { - expect(scopesWithinAbility(["read"], buildJwtAbility(["read:all"]))).toEqual({ + expect( + scopesWithinAbility(["read", "read:runs:", "read:tags: "], buildJwtAbility(["read:all"])) + ).toEqual({ ok: false, - deniedScopes: ["read"], + deniedScopes: ["read", "read:runs:", "read:tags: "], }); }); }); +describe("buildScope", () => { + it("distinguishes an absent ID from an explicitly empty ID", () => { + expect(buildScope("read", "runs")).toBe("read:runs"); + expect(() => buildScope("read", "runs", "")).toThrow("Scope resource IDs must not be empty"); + expect(() => buildScope("read", "runs", " ")).toThrow("Scope resource IDs must not be empty"); + }); + + it("preserves nonempty IDs exactly", () => { + expect(buildScope("read", "tags", " env:staging ")).toBe("read:tags: env:staging "); + }); +}); + describe("buildJwtAbility — array resources", () => { it("authorizes when any resource in the array passes a scope check", () => { const ability = buildJwtAbility(["read:batch:batch_abc"]); diff --git a/internal-packages/run-ops-database/scripts/migrate.mjs b/internal-packages/run-ops-database/scripts/migrate.mjs index d473c1c98dd..300c19baf6d 100644 --- a/internal-packages/run-ops-database/scripts/migrate.mjs +++ b/internal-packages/run-ops-database/scripts/migrate.mjs @@ -11,7 +11,6 @@ import { fileURLToPath } from "node:url"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const expand = (value) => value?.replace(/\$\{(\w+)\}/g, (_, k) => process.env[k] ?? ""); -const redact = (url) => url.replace(/:\/\/[^@]*@/, "://***@"); const subcommand = process.argv[2] === "status" ? "status" : "deploy"; @@ -26,9 +25,7 @@ if (!databaseUrl) { process.exit(0); } -console.log( - `Running \`prisma migrate ${subcommand}\` against the run-ops database (${redact(databaseUrl)})` -); +console.log(`Running \`prisma migrate ${subcommand}\` against the configured run-ops database`); const result = spawnSync("prisma", ["migrate", subcommand, "--schema", "prisma/schema.prisma"], { cwd: packageRoot, diff --git a/packages/build/CHANGELOG.md b/packages/build/CHANGELOG.md index a6bca8611e9..7c8f648ad86 100644 --- a/packages/build/CHANGELOG.md +++ b/packages/build/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/build +## 4.6.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.6.2` + ## 4.6.1 ### Patch Changes diff --git a/packages/build/package.json b/packages/build/package.json index d40685e75a3..14a140cc618 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/build", - "version": "4.6.1", + "version": "4.6.2", "description": "trigger.dev build extensions", "license": "MIT", "publishConfig": { @@ -82,7 +82,7 @@ }, "dependencies": { "@prisma/config": "^6.10.0", - "@trigger.dev/core": "workspace:4.6.1", + "@trigger.dev/core": "workspace:4.6.2", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "tinyglobby": "^0.2.2", diff --git a/packages/cli-v3/CHANGELOG.md b/packages/cli-v3/CHANGELOG.md index ac412a4fc7a..73f093a31c9 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,14 @@ # trigger.dev +## 4.6.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.6.2` + - `@trigger.dev/build@4.6.2` + - `@trigger.dev/schema-to-json@4.6.2` + ## 4.6.1 ### Patch Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 952bb9be41b..9be314e50b4 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -1,6 +1,6 @@ { "name": "trigger.dev", - "version": "4.6.1", + "version": "4.6.2", "description": "A Command-Line Interface for Trigger.dev projects", "type": "module", "license": "MIT", @@ -83,9 +83,9 @@ "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/instrumentation": "0.218.0", "@s2-dev/streamstore": "^0.25.0", - "@trigger.dev/build": "workspace:4.6.1", - "@trigger.dev/core": "workspace:4.6.1", - "@trigger.dev/schema-to-json": "workspace:4.6.1", + "@trigger.dev/build": "workspace:4.6.2", + "@trigger.dev/core": "workspace:4.6.2", + "@trigger.dev/schema-to-json": "workspace:4.6.2", "ansi-escapes": "^7.0.0", "braces": "^3.0.3", "c12": "^1.11.1", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 58ea074af08..e4b652e34f4 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # internal-platform +## 4.6.2 + +### Patch Changes + +- Authorize stored realtime payload and output downloads against their owning run. Large packet hydration continues to work for run-, task-, tag-, and batch-scoped public tokens without permitting arbitrary packet reads. ([`76031b0a1`](https://github.com/triggerdotdev/trigger.dev/commit/76031b0a1e808edf8c0b42644d9184f8ace205b1)) + ## 4.6.1 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index cb0b2bb3c02..d75f1d6beed 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core", - "version": "4.6.1", + "version": "4.6.2", "description": "Core code used across the Trigger.dev SDK and platform", "license": "MIT", "publishConfig": { diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index a74feb8ed69..58d074e61b4 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -686,6 +686,35 @@ export class ApiClient { ); } + /** + * Presigned GET URL for a run's stored payload or output, authorized against the run. + * Falls back to the legacy packet route on servers that predate this endpoint. + */ + async getRunPacketUrl( + runId: string, + field: "payload" | "output", + path: string, + requestOptions?: ZodFetchOptions + ) { + try { + return await zodfetch( + CreateUploadPayloadUrlResponseBody, + `${this.baseUrl}/api/v1/runs/${encodeURIComponent(runId)}/packets/${field}`, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return await this.getPayloadUrl(path, requestOptions); + } + + throw error; + } + } + /** Presigned PUT URL for a `chat.agent` session snapshot. */ createChatSnapshotUploadUrl(sessionId: string, requestOptions?: ZodFetchOptions) { return zodfetch( diff --git a/packages/core/src/v3/apiClient/runStream.ts b/packages/core/src/v3/apiClient/runStream.ts index f0ec1267f97..5ae5b961fb0 100644 --- a/packages/core/src/v3/apiClient/runStream.ts +++ b/packages/core/src/v3/apiClient/runStream.ts @@ -743,7 +743,10 @@ export type RunSubscriptionOptions = RunShapeStreamOptions & { export class RunSubscription { private stream: AsyncIterableStream>; - private packetCache = new Map(); + private packetCache = new Map< + string, + { data: string | undefined; dataType: string; value: any } + >(); private _closeOnComplete: boolean; private _isRunComplete = false; @@ -874,14 +877,27 @@ export class RunSubscription { return; } - const cachedResult = this.packetCache.get(`${row.friendlyId}/${key}`); + const cacheKey = `${row.friendlyId}/${key}`; + const cachedResult = this.packetCache.get(cacheKey); - if (typeof cachedResult !== "undefined") { - return cachedResult; + if ( + cachedResult && + cachedResult.data === packet.data && + cachedResult.dataType === packet.dataType + ) { + return cachedResult.value; } - const result = await conditionallyImportAndParsePacket(packet, this.options.client); - this.packetCache.set(`${row.friendlyId}/${key}`, result); + const field = key as "payload" | "output"; + const result = await conditionallyImportAndParsePacket(packet, this.options.client, { + runId: row.friendlyId, + field, + }); + this.packetCache.set(cacheKey, { + data: packet.data, + dataType: packet.dataType, + value: result, + }); return result; }) diff --git a/packages/core/src/v3/utils/ioSerialization.ts b/packages/core/src/v3/utils/ioSerialization.ts index a63a4397303..d574d558092 100644 --- a/packages/core/src/v3/utils/ioSerialization.ts +++ b/packages/core/src/v3/utils/ioSerialization.ts @@ -24,6 +24,11 @@ export type ParsePacketOptions = { filteredKeys?: string[]; }; +export type RunPacketContext = { + runId: string; + field: "payload" | "output"; +}; + export async function parsePacket(value: IOPacket, options?: ParsePacketOptions): Promise { if (!value.data) { return undefined; @@ -75,9 +80,10 @@ export async function parsePacketAsJson( export async function conditionallyImportAndParsePacket( value: IOPacket, - client?: ApiClient + client?: ApiClient, + runPacket?: RunPacketContext ): Promise { - const importedPacket = await conditionallyImportPacket(value, undefined, client); + const importedPacket = await conditionallyImportPacket(value, undefined, client, runPacket); return await parsePacket(importedPacket); } @@ -220,19 +226,20 @@ async function exportPacket( export async function conditionallyImportPacket( packet: IOPacket, tracer?: TriggerTracer, - client?: ApiClient + client?: ApiClient, + runPacket?: RunPacketContext ): Promise { if (packet.dataType !== "application/store") { return packet; } if (!tracer) { - return await importPacket(packet, undefined, client); + return await importPacket(packet, undefined, client, runPacket); } else { const result = await tracer.startActiveSpan( "store.downloadPayload", async (span) => { - return await importPacket(packet, span, client); + return await importPacket(packet, span, client, runPacket); }, { attributes: { @@ -270,7 +277,12 @@ export async function resolvePresignedPacketUrl( } } -async function importPacket(packet: IOPacket, span?: Span, client?: ApiClient): Promise { +async function importPacket( + packet: IOPacket, + span?: Span, + client?: ApiClient, + runPacket?: RunPacketContext +): Promise { if (!packet.data) { return packet; } @@ -281,7 +293,9 @@ async function importPacket(packet: IOPacket, span?: Span, client?: ApiClient): return packet; } - const presignedResponse = await $client.getPayloadUrl(packet.data); + const presignedResponse = runPacket + ? await $client.getRunPacketUrl(runPacket.runId, runPacket.field, packet.data) + : await $client.getPayloadUrl(packet.data); const response = await zodfetch(z.any(), presignedResponse.presignedUrl, undefined, { retry: ioRetryOptions, diff --git a/packages/core/test/ioSerialization.test.ts b/packages/core/test/ioSerialization.test.ts index 9a2d7accfe1..daa1ad61706 100644 --- a/packages/core/test/ioSerialization.test.ts +++ b/packages/core/test/ioSerialization.test.ts @@ -353,6 +353,60 @@ describe("ioSerialization", () => { }); }); + describe("run packet downloads", () => { + it("falls back to the legacy packet route when the scoped route is absent", async () => { + const requests: string[] = []; + const server = await createTestHttpServer({ + defineRoutes(router) { + router.get("/api/v1/runs/:runId/packets/:field", ({ req }) => { + requests.push(req.url); + return Response.json({ error: "Not found" }, { status: 404 }); + }); + router.get("/api/v1/packets/:path", ({ req }) => { + requests.push(req.url); + return Response.json({ presignedUrl: "https://example.com/packet" }); + }); + }, + }); + + try { + const client = new ApiClient(server.http.url().origin, "tr-passed"); + const result = await client.getRunPacketUrl("run_123", "payload", "run_123/payload.json"); + + expect(result).toEqual({ presignedUrl: "https://example.com/packet" }); + expect(requests).toHaveLength(2); + } finally { + await server.close(); + } + }); + + it("does not fall back after an authorization failure", async () => { + let legacyRequests = 0; + const server = await createTestHttpServer({ + defineRoutes(router) { + router.get("/api/v1/runs/:runId/packets/:field", () => { + return Response.json({ error: "Forbidden" }, { status: 403 }); + }); + router.get("/api/v1/packets/:path", () => { + legacyRequests++; + return Response.json({ presignedUrl: "https://example.com/packet" }); + }); + }, + }); + + try { + const client = new ApiClient(server.http.url().origin, "tr-passed"); + + await expect( + client.getRunPacketUrl("run_123", "payload", "run_123/payload.json") + ).rejects.toMatchObject({ status: 403 }); + expect(legacyRequests).toBe(0); + } finally { + await server.close(); + } + }); + }); + describe("conditionallyExportPacket", () => { // A payload large enough to exceed OFFLOAD_IO_PACKET_LENGTH_LIMIT (128KB) so it offloads. const largePayload = "x".repeat(200_000); diff --git a/packages/core/test/runStream.test.ts b/packages/core/test/runStream.test.ts index 76db11e77d9..ef4cd8f9932 100644 --- a/packages/core/test/runStream.test.ts +++ b/packages/core/test/runStream.test.ts @@ -4,6 +4,7 @@ import type { StreamSubscription, StreamSubscriptionFactory, } from "../src/v3/apiClient/runStream.js"; +import type { ApiClient } from "../src/v3/apiClient/index.js"; import { RunSubscription, SSEStreamSubscription } from "../src/v3/apiClient/runStream.js"; import type { SubscribeRunRawShape } from "../src/v3/schemas/api.js"; @@ -165,6 +166,57 @@ describe("RunSubscription", () => { }); }); + it("authorizes stored payloads and outputs through their run", async () => { + const getRunPacketUrl = vi.fn(async (_runId: string, field: "payload" | "output") => ({ + presignedUrl: `data:application/json,${encodeURIComponent( + JSON.stringify({ source: field }) + )}`, + })); + const getPayloadUrl = vi.fn(); + const client = { getRunPacketUrl, getPayloadUrl } as unknown as ApiClient; + const shapes: SubscribeRunRawShape[] = [ + { + id: "123", + friendlyId: "run_123", + taskIdentifier: "test-task", + status: "COMPLETED_SUCCESSFULLY", + createdAt: new Date(), + updatedAt: new Date(), + completedAt: new Date(), + number: 1, + usageDurationMs: 100, + costInCents: 0, + baseCostInCents: 0, + isTest: false, + runTags: [], + payload: "run_123/payload.json", + payloadType: "application/store", + output: "run_123-1/output.json", + outputType: "application/store", + realtimeStreams: [], + }, + ]; + + const subscription = new RunSubscription({ + runShapeStream: createTestShapeStream(shapes), + stopRunShapeStream: () => {}, + streamFactory: new TestStreamSubscriptionFactory(), + closeOnComplete: true, + abortController: new AbortController(), + client, + }); + + const results = await convertAsyncIterableToArray(subscription); + + expect(results[0]).toMatchObject({ + payload: { source: "payload" }, + output: { source: "output" }, + }); + expect(getRunPacketUrl).toHaveBeenCalledWith("run_123", "payload", "run_123/payload.json"); + expect(getRunPacketUrl).toHaveBeenCalledWith("run_123", "output", "run_123-1/output.json"); + expect(getPayloadUrl).not.toHaveBeenCalled(); + }); + it("should keep stream open when closeOnComplete is false", async () => { const shapes: SubscribeRunRawShape[] = [ { diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts index 563e01ca530..c52b2587033 100644 --- a/packages/plugins/src/rbac.ts +++ b/packages/plugins/src/rbac.ts @@ -152,19 +152,22 @@ export interface RbacAbility { * drift, and the difference would silently change what a token grants. */ function parseScope(scope: string): { action: string; type?: string; id?: string } | undefined { - // Only the first two colons are delimiters — everything after the - // second colon is the resource id (which may itself contain colons, - // e.g. user-provided tags like "env:staging"). Naive - // `split(":")` + 3-tuple destructuring truncates such ids. - const parts = scope.split(":"); - const action = parts[0]; + const firstDelimiter = scope.indexOf(":"); + const action = firstDelimiter === -1 ? scope : scope.slice(0, firstDelimiter); if (!action) return undefined; + if (firstDelimiter === -1) return { action }; - return { - action, - type: parts[1] || undefined, - id: parts.length > 2 ? parts.slice(2).join(":") || undefined : undefined, - }; + const secondDelimiter = scope.indexOf(":", firstDelimiter + 1); + const type = scope.slice( + firstDelimiter + 1, + secondDelimiter === -1 ? undefined : secondDelimiter + ); + if (secondDelimiter === -1) return { action, type: type || undefined }; + + const id = scope.slice(secondDelimiter + 1); + if (!id.trim()) return undefined; + + return { action, type: type || undefined, id }; } /** Only exact bare `admin` represents unrestricted access. */ @@ -217,33 +220,65 @@ export function buildScope( type: RbacScopeResourceType, id?: string ): string { - return id ? `${action}:${type}:${id}` : `${action}:${type}`; + if (id !== undefined && !id.trim()) { + throw new Error("Scope resource IDs must not be empty"); + } + + return id === undefined ? `${action}:${type}` : `${action}:${type}:${id}`; } +type ScopeTarget = true | Set; + export function buildJwtAbility(scopes: string[]): RbacAbility { - const matches = (action: string, r: RbacResource): boolean => - scopes.some((scope) => { - const parsed = parseScope(scope); - if (!parsed) return false; - - // Bare `admin` is the universal wildcard. `admin:` is *not* — - // it falls through to normal matching as action="admin" against - // resources of that type. Treating `admin:` as universal - // would silently broaden any such tokens beyond the narrow, - // route-listed grant they had before scope-based abilities. - if (parsed.action === "admin" && !parsed.type) return true; - if (parsed.action !== action && parsed.action !== "*") return false; - if (parsed.type === "all") return true; - if (parsed.type !== r.type) return false; - if (!parsed.id) return true; - return parsed.id === r.id; - }); + const allResourceActions = new Set(); + const targetsByAction = new Map>(); + let grantsFullAccess = false; + + for (const scope of scopes) { + const parsed = parseScope(scope); + if (!parsed) continue; + + if (parsed.action === "admin" && !parsed.type) { + grantsFullAccess = true; + continue; + } + if (!parsed.type) continue; + if (parsed.type === "all") { + allResourceActions.add(parsed.action); + continue; + } + + let targetsByType = targetsByAction.get(parsed.action); + if (!targetsByType) { + targetsByType = new Map(); + targetsByAction.set(parsed.action, targetsByType); + } + + const target = targetsByType.get(parsed.type); + if (parsed.id === undefined) { + targetsByType.set(parsed.type, true); + } else if (target !== true) { + const ids = target ?? new Set(); + ids.add(parsed.id); + targetsByType.set(parsed.type, ids); + } + } + + const matchesAction = (action: string, resource: RbacResource): boolean => { + if (allResourceActions.has(action)) return true; + + const target = targetsByAction.get(action)?.get(resource.type); + if (target === true) return true; + return resource.id !== undefined && target?.has(resource.id) === true; + }; + + const matches = (action: string, resource: RbacResource): boolean => + grantsFullAccess || matchesAction(action, resource) || matchesAction("*", resource); + return { can(action: string, resource: RbacResource | RbacResource[]): boolean { - // Array form means "any element passes → authorized", matching the - // legacy multi-key authorization semantic. return Array.isArray(resource) - ? resource.some((r) => matches(action, r)) + ? resource.some((candidate) => matches(action, candidate)) : matches(action, resource); }, canSuper(): boolean { diff --git a/packages/python/CHANGELOG.md b/packages/python/CHANGELOG.md index 05ef4cb682d..3cd97527292 100644 --- a/packages/python/CHANGELOG.md +++ b/packages/python/CHANGELOG.md @@ -1,5 +1,14 @@ # @trigger.dev/python +## 4.6.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/sdk@4.6.2` + - `@trigger.dev/core@4.6.2` + - `@trigger.dev/build@4.6.2` + ## 4.6.1 ### Patch Changes diff --git a/packages/python/package.json b/packages/python/package.json index 1ac3647f90e..18a0024a01e 100644 --- a/packages/python/package.json +++ b/packages/python/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/python", - "version": "4.6.1", + "version": "4.6.2", "description": "Python runtime and build extension for Trigger.dev", "license": "MIT", "publishConfig": { @@ -45,7 +45,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.6.1", + "@trigger.dev/core": "workspace:4.6.2", "tinyexec": "^0.3.2" }, "devDependencies": { @@ -55,12 +55,12 @@ "typescript": "catalog:", "tsx": "4.17.0", "@arethetypeswrong/cli": "^0.18.5", - "@trigger.dev/build": "workspace:4.6.1", - "@trigger.dev/sdk": "workspace:4.6.1" + "@trigger.dev/build": "workspace:4.6.2", + "@trigger.dev/sdk": "workspace:4.6.2" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^4.6.1", - "@trigger.dev/build": "workspace:^4.6.1" + "@trigger.dev/sdk": "workspace:^4.6.2", + "@trigger.dev/build": "workspace:^4.6.2" }, "engines": { "node": ">=18.20.0" diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index 3a03198b06d..3f342cd5831 100644 --- a/packages/react-hooks/CHANGELOG.md +++ b/packages/react-hooks/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/react-hooks +## 4.6.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.6.2` + ## 4.6.1 ### Patch Changes diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index 5b4019a3e45..5fcbe46bb73 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/react-hooks", - "version": "4.6.1", + "version": "4.6.2", "description": "trigger.dev react hooks", "license": "MIT", "publishConfig": { @@ -37,7 +37,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.6.1", + "@trigger.dev/core": "workspace:^4.6.2", "swr": "^2.2.5" }, "devDependencies": { diff --git a/packages/redis-worker/CHANGELOG.md b/packages/redis-worker/CHANGELOG.md index b7ad6c21e8b..12c7813a7cc 100644 --- a/packages/redis-worker/CHANGELOG.md +++ b/packages/redis-worker/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/redis-worker +## 4.6.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.6.2` + ## 4.6.1 ### Patch Changes diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index 8c7f484397c..a6a6cf3ce5e 100644 --- a/packages/redis-worker/package.json +++ b/packages/redis-worker/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/redis-worker", - "version": "4.6.1", + "version": "4.6.2", "description": "Redis worker for trigger.dev", "license": "MIT", "publishConfig": { @@ -23,7 +23,7 @@ "test": "vitest --sequence.concurrent=false --no-file-parallelism" }, "dependencies": { - "@trigger.dev/core": "workspace:4.6.1", + "@trigger.dev/core": "workspace:4.6.2", "nanoid": "^5.1.16", "p-limit": "^6.2.0", "seedrandom": "^3.0.5", diff --git a/packages/rsc/CHANGELOG.md b/packages/rsc/CHANGELOG.md index d474948ef0e..9dd3a24fc0c 100644 --- a/packages/rsc/CHANGELOG.md +++ b/packages/rsc/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/rsc +## 4.6.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.6.2` + ## 4.6.1 ### Patch Changes diff --git a/packages/rsc/package.json b/packages/rsc/package.json index e78f7c6489e..6d3a8681826 100644 --- a/packages/rsc/package.json +++ b/packages/rsc/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/rsc", - "version": "4.6.1", + "version": "4.6.2", "description": "trigger.dev rsc", "license": "MIT", "publishConfig": { @@ -37,7 +37,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.6.1", + "@trigger.dev/core": "workspace:^4.6.2", "mlly": "^1.7.1", "react": "19.0.0-rc.1", "react-dom": "19.0.0-rc.1" diff --git a/packages/schema-to-json/CHANGELOG.md b/packages/schema-to-json/CHANGELOG.md index 35daf186e8a..c07b749cf81 100644 --- a/packages/schema-to-json/CHANGELOG.md +++ b/packages/schema-to-json/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/schema-to-json +## 4.6.2 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.6.2` + ## 4.6.1 ### Patch Changes diff --git a/packages/schema-to-json/package.json b/packages/schema-to-json/package.json index ea20d299925..17e35016ba2 100644 --- a/packages/schema-to-json/package.json +++ b/packages/schema-to-json/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/schema-to-json", - "version": "4.6.1", + "version": "4.6.2", "description": "Convert various schema validation libraries to JSON Schema", "license": "MIT", "publishConfig": { diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md index 3913c69b243..5d3a3e2b72e 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,15 @@ # @trigger.dev/sdk +## 4.6.2 + +### Patch Changes + +- Show warm idle time and durable waits separately in chat agent traces. Durable waits now open the waitpoint inspector while waiting and after completion. Message span names are shorter, and repeated session IDs no longer crowd message-wait and default output-stream spans. ([`0754931cc`](https://github.com/triggerdotdev/trigger.dev/commit/0754931cca708fc7334470b81c0f446afed7ef0a)) +- chat.agent: the between-turns compaction check now receives the last step's token usage (the context the model actually held) instead of the turn's sum over every tool-calling step, so a single tool-using turn no longer compacts a short conversation. The summed figure is still available as `turnUsage` on the event. A head-start handover whose pending tool call completes under the same message id now replaces its spliced partial in the model lane directly instead of falling back to a full reconversion. ([`04c837569`](https://github.com/triggerdotdev/trigger.dev/commit/04c8375696bacab65ad7bb2d9cf2b9df616b1343)) +- Validate resource IDs when creating scoped public tokens. Explicitly empty IDs are now rejected instead of being interpreted as type-wide permissions. ([`22f8fb2b5`](https://github.com/triggerdotdev/trigger.dev/commit/22f8fb2b5b1d05ac1741facb87873454b7805eac)) +- Updated dependencies: + - `@trigger.dev/core@4.6.2` + ## 4.6.1 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index c060605411a..77ee98c71d0 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sdk", - "version": "4.6.1", + "version": "4.6.2", "description": "trigger.dev Node.JS SDK", "license": "MIT", "publishConfig": { @@ -80,7 +80,7 @@ "dependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/semantic-conventions": "1.41.1", - "@trigger.dev/core": "workspace:4.6.1", + "@trigger.dev/core": "workspace:4.6.2", "uncrypto": "^0.1.3" }, "devDependencies": { diff --git a/packages/trigger-sdk/src/v3/auth.test.ts b/packages/trigger-sdk/src/v3/auth.test.ts index c32ce788a53..32950100674 100644 --- a/packages/trigger-sdk/src/v3/auth.test.ts +++ b/packages/trigger-sdk/src/v3/auth.test.ts @@ -99,6 +99,25 @@ describe("public token API key routing", () => { ]); }); + it.each(["", " "])("rejects an explicitly empty resource ID", async (runId) => { + const key = "tr_prod_0123456789abcdefghijklmn"; + const promise = apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken({ scopes: { read: { runs: [runId] } } }) + ); + + await expect(promise).rejects.toThrow("Public token scope resource IDs must not be empty"); + expect(requests).toEqual([]); + }); + + it("preserves nonempty resource IDs exactly", async () => { + const key = "tr_prod_sk_0123456789abcdefghijklmn"; + await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => + auth.createPublicToken({ scopes: { read: { tags: [" env:staging "] } } }) + ); + + expect(requests[0]?.body).toEqual({ scopes: ["read:tags: env:staging "] }); + }); + it("keeps root key self-minting unchanged", async () => { const key = "tr_prod_0123456789abcdefghijklmn"; const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () => diff --git a/packages/trigger-sdk/src/v3/auth.ts b/packages/trigger-sdk/src/v3/auth.ts index d36f55f6669..64bd804469b 100644 --- a/packages/trigger-sdk/src/v3/auth.ts +++ b/packages/trigger-sdk/src/v3/auth.ts @@ -488,10 +488,10 @@ function flattenScopes(permissions: PublicTokenPermissions): string[] { for (const [property, value] of Object.entries(properties)) { if (Array.isArray(value)) { for (const item of value) { - flattenedPermissions.push(`${action}:${property}:${item}`); + flattenedPermissions.push(scopeWithResourceId(action, property, item)); } } else if (typeof value === "string") { - flattenedPermissions.push(`${action}:${property}:${value}`); + flattenedPermissions.push(scopeWithResourceId(action, property, value)); } else if (typeof value === "boolean" && value) { flattenedPermissions.push(`${action}:${property}`); } @@ -502,3 +502,11 @@ function flattenScopes(permissions: PublicTokenPermissions): string[] { return flattenedPermissions; } + +function scopeWithResourceId(action: string, resource: string, id: string): string { + if (!id.trim()) { + throw new Error("Public token scope resource IDs must not be empty"); + } + + return `${action}:${resource}:${id}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cfa24385e8..69bb1ccc99a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1500,7 +1500,7 @@ importers: specifier: ^6.10.0 version: 6.19.0(magicast@0.3.5) '@trigger.dev/core': - specifier: workspace:4.6.1 + specifier: workspace:4.6.2 version: link:../core mlly: specifier: ^1.7.1 @@ -1567,13 +1567,13 @@ importers: specifier: ^0.25.0 version: 0.25.0(supports-color@10.0.0) '@trigger.dev/build': - specifier: workspace:4.6.1 + specifier: workspace:4.6.2 version: link:../build '@trigger.dev/core': - specifier: workspace:4.6.1 + specifier: workspace:4.6.2 version: link:../core '@trigger.dev/schema-to-json': - specifier: workspace:4.6.1 + specifier: workspace:4.6.2 version: link:../schema-to-json ansi-escapes: specifier: ^7.0.0 @@ -1903,7 +1903,7 @@ importers: packages/python: dependencies: '@trigger.dev/core': - specifier: workspace:4.6.1 + specifier: workspace:4.6.2 version: link:../core tinyexec: specifier: ^0.3.2 @@ -1913,10 +1913,10 @@ importers: specifier: ^0.18.5 version: 0.18.5 '@trigger.dev/build': - specifier: workspace:4.6.1 + specifier: workspace:4.6.2 version: link:../build '@trigger.dev/sdk': - specifier: workspace:4.6.1 + specifier: workspace:4.6.2 version: link:../trigger-sdk '@types/node': specifier: 24.13.3 @@ -1937,7 +1937,7 @@ importers: packages/react-hooks: dependencies: '@trigger.dev/core': - specifier: workspace:^4.6.1 + specifier: workspace:^4.6.2 version: link:../core react: specifier: 18.3.1 @@ -1971,7 +1971,7 @@ importers: packages/redis-worker: dependencies: '@trigger.dev/core': - specifier: workspace:4.6.1 + specifier: workspace:4.6.2 version: link:../core cron-parser: specifier: ^4.9.0 @@ -2017,7 +2017,7 @@ importers: packages/rsc: dependencies: '@trigger.dev/core': - specifier: workspace:^4.6.1 + specifier: workspace:^4.6.2 version: link:../core mlly: specifier: ^1.7.1 @@ -2106,7 +2106,7 @@ importers: specifier: 1.41.1 version: 1.41.1 '@trigger.dev/core': - specifier: workspace:4.6.1 + specifier: workspace:4.6.2 version: link:../core react: specifier: 18.3.1 diff --git a/scripts/unpack-worker.js b/scripts/unpack-worker.js index de356960e46..13e7b83d6a5 100644 --- a/scripts/unpack-worker.js +++ b/scripts/unpack-worker.js @@ -32,14 +32,30 @@ try { const id = data.id; + // The id becomes the extraction root, so it must be a single path component. + if ( + typeof id !== "string" || + id.length === 0 || + id === "." || + id === ".." || + id.includes("/") || + id.includes("\\") || + id.includes("\0") + ) { + throw new Error(`Unsafe worker id: ${id}`); + } + console.log(`Extracting files for: ${id} to ${destDir}`); destDir = path.join(destDir, id); console.log(`Extracting files to: ${destDir}`); + // Resolve the extraction root once so we can keep every file inside it. + const extractionRoot = path.resolve(destDir); + // Create the destination directory if it doesn't exist - fs.mkdirSync(destDir, { recursive: true }); + fs.mkdirSync(extractionRoot, { recursive: true }); // Process each item in the array const sourceFiles = data.metadata.sourceFiles; @@ -49,7 +65,12 @@ try { const decompressedContent = decompressContent(file.contents); // Combine destination directory with file path - const fullPath = path.join(destDir, file.filePath); + const fullPath = path.resolve(extractionRoot, file.filePath); + + // Reject any path that escapes the extraction root (zip-slip via ../ etc.) + if (fullPath !== extractionRoot && !fullPath.startsWith(extractionRoot + path.sep)) { + throw new Error(`Refusing to write outside destination: ${file.filePath}`); + } // Create directory structure if it doesn't exist const dirPath = path.dirname(fullPath); @@ -61,7 +82,7 @@ try { console.log(`Created file: ${fullPath}`); }); - console.log(`\nAll files have been extracted to: ${destDir}`); + console.log(`\nAll files have been extracted to: ${extractionRoot}`); } catch (error) { console.error(error); process.exit(1);