diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 71eb4aa1b1..4417db8710 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -51,7 +51,7 @@ export function CopyableText({ value, className }: { value: string; className?: )} } - content={copied ? "Copied!" : "Copy text"} + content={copied ? "Copied!" : "Copy"} className="font-sans" disableHoverableContent /> diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index d1fe0d735b..07bafd13b8 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -33,6 +33,13 @@ const variations = { "transition group-hover:text-text-bright group-disabled:group-hover:text-text-dimmed" ), }, + medium: { + container: + "flex items-center gap-x-2 rounded-md hover:bg-tertiary py-1.5 px-2 transition focus-custom", + root: "h-4 w-8", + thumb: "size-3.5 data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0", + text: "text-sm text-text-dimmed", + }, }; type SwitchProps = React.ComponentPropsWithoutRef & { diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index 07c72309a8..a51c78cc82 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -349,7 +349,9 @@ export const TableCellMenu = forwardRef< variants[variant].menuButtonDivider )} > -
{hiddenButtons}
+
+ {hiddenButtons} +
)} {/* Always visible buttons */} diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 1123a66b62..f03121ae83 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -1,3 +1,4 @@ +import { flipCauseOption } from "effect/Cause"; import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; @@ -48,6 +49,7 @@ export class EnvironmentVariablesPresenter { key: true, }, }, + isSecret: true, }, }, }, @@ -82,34 +84,43 @@ export class EnvironmentVariablesPresenter { }, }); - const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)); + const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)).filter( + (e) => e.orgMember?.userId === userId || e.orgMember === null + ); const repository = new EnvironmentVariablesRepository(this.#prismaClient); const variables = await repository.getProject(project.id); return { - environmentVariables: environmentVariables.map((environmentVariable) => { - const variable = variables.find((v) => v.key === environmentVariable.key); + environmentVariables: environmentVariables + .flatMap((environmentVariable) => { + const variable = variables.find((v) => v.key === environmentVariable.key); - return { - id: environmentVariable.id, - key: environmentVariable.key, - values: sortedEnvironments.reduce((previous, env) => { + return sortedEnvironments.flatMap((env) => { const val = variable?.values.find((v) => v.environment.id === env.id); - previous[env.id] = { - value: val?.value, - environment: { type: env.type, id: env.id }, - }; - return { ...previous }; - }, {} as Record), - }; - }), - environments: sortedEnvironments - .filter((e) => e.orgMember?.userId === userId || e.orgMember === null) - .map((environment) => ({ - id: environment.id, - type: environment.type, - })), + const isSecret = + environmentVariable.values.find((v) => v.environmentId === env.id)?.isSecret ?? false; + + if (!val) { + return []; + } + + return [ + { + id: environmentVariable.id, + key: environmentVariable.key, + environment: { type: env.type, id: env.id }, + value: isSecret ? "" : val.value, + isSecret, + }, + ]; + }); + }) + .sort((a, b) => a.key.localeCompare(b.key)), + environments: sortedEnvironments.map((environment) => ({ + id: environment.id, + type: environment.type, + })), hasStaging: environments.some((environment) => environment.type === "STAGING"), }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 00bb1f0736..cec4c570cc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -40,6 +40,7 @@ import { useList } from "~/hooks/useList"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; +import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { @@ -87,6 +88,10 @@ const schema = z.object({ if (i === "false") return false; return; }, z.boolean()), + isSecret: z.preprocess((i) => { + if (i === "true") return true; + return false; + }, z.boolean()), environmentIds: z.preprocess((i) => { if (typeof i === "string") return [i]; @@ -194,7 +199,7 @@ export default function Page() { shouldRevalidate: "onSubmit", }); - const [revealAll, setRevealAll] = useState(false); + const [revealAll, setRevealAll] = useState(true); useEffect(() => { setIsOpen(true); @@ -209,14 +214,10 @@ export default function Page() { } }} > - - New environment variables -
-
+ + New environment variables + +
@@ -237,7 +238,7 @@ export default function Page() { @@ -257,8 +258,21 @@ export default function Page() { file when running locally. + + Secret value} + /> + + If enabled, you and your team will not be able to view the values after creation. + + - +
@@ -280,61 +294,47 @@ export default function Page() { {form.error} - - - -
- } - cancelButton={ - - Cancel - - } - />
+ + + + + } + cancelButton={ + + Cancel + + } + />
); } -function FieldLayout({ - children, - showDeleteButton, -}: { - children: React.ReactNode; - showDeleteButton: boolean; -}) { - return ( -
- {children} -
- ); +function FieldLayout({ children }: { children: React.ReactNode }) { + return
{children}
; } function VariableFields({ @@ -409,9 +409,9 @@ function VariableFields({ /> ); })} -
1 && "pr-10")}> +
- Tip: Paste your .env into this form to populate it. + Tip: Paste all your .env values at once into this form to populate it.
{fields.key.error} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index cd8317580c..b7c24c8189 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -1,8 +1,9 @@ -import { useForm } from "@conform-to/react"; +import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { BookOpenIcon, InformationCircleIcon, + LockClosedIcon, PencilSquareIcon, PlusIcon, TrashIcon, @@ -14,8 +15,7 @@ import { json, redirectDocument, } from "@remix-run/server-runtime"; -import { type RuntimeEnvironment } from "@trigger.dev/database"; -import { Fragment, useState } from "react"; +import { useMemo, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; @@ -23,6 +23,7 @@ import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { CopyableText } from "~/components/primitives/CopyableText"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -43,6 +44,7 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; @@ -63,8 +65,8 @@ import { } from "~/utils/pathBuilder"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { - DeleteEnvironmentVariable, - EditEnvironmentVariable, + DeleteEnvironmentVariableValue, + EditEnvironmentVariableValue, } from "~/v3/environmentVariables/repository"; export const meta: MetaFunction = () => { @@ -101,8 +103,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; const schema = z.discriminatedUnion("action", [ - z.object({ action: z.literal("edit"), key: z.string(), ...EditEnvironmentVariable.shape }), - z.object({ action: z.literal("delete"), key: z.string(), ...DeleteEnvironmentVariable.shape }), + z.object({ action: z.literal("edit"), ...EditEnvironmentVariableValue.shape }), + z.object({ + action: z.literal("delete"), + key: z.string(), + ...DeleteEnvironmentVariableValue.shape, + }), ]); export const action = async ({ request, params }: ActionFunctionArgs) => { @@ -143,7 +149,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (submission.value.action) { case "edit": { const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.edit(project.id, submission.value); + const result = await repository.editValue(project.id, submission.value); if (!result.success) { submission.error.key = result.error; @@ -166,7 +172,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } case "delete": { const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.delete(project.id, submission.value); + const result = await repository.deleteValue(project.id, submission.value); if (!result.success) { submission.error.key = result.error; @@ -188,11 +194,48 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { export default function Page() { const [revealAll, setRevealAll] = useState(false); - const { environmentVariables, environments, hasStaging } = useTypedLoaderData(); + const { environmentVariables, environments } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); + // Add isFirst and isLast to each environment variable + // They're set based on if they're the first or last time that `key` has been seen in the list + const groupedEnvironmentVariables = useMemo(() => { + // Create a map to track occurrences of each key + const keyOccurrences = new Map(); + + // First pass: count total occurrences of each key + environmentVariables.forEach((variable) => { + keyOccurrences.set(variable.key, (keyOccurrences.get(variable.key) || 0) + 1); + }); + + // Second pass: add isFirstTime, isLastTime, and occurrences flags + const seenKeys = new Set(); + const currentOccurrences = new Map(); + + return environmentVariables.map((variable) => { + // Track current occurrence number for this key + const currentCount = (currentOccurrences.get(variable.key) || 0) + 1; + currentOccurrences.set(variable.key, currentCount); + + const totalOccurrences = keyOccurrences.get(variable.key) || 1; + const isFirstTime = !seenKeys.has(variable.key); + const isLastTime = currentCount === totalOccurrences; + + if (isFirstTime) { + seenKeys.add(variable.key); + } + + return { + ...variable, + isFirstTime, + isLastTime, + occurences: totalOccurrences, + }; + }); + }, [environmentVariables]); + return ( @@ -230,55 +273,87 @@ export default function Page() { - Key - {environments.map((environment) => ( - - - - ))} - Actions + Key + Value + Environment + + Actions + - {environmentVariables.length > 0 ? ( - environmentVariables.map((variable) => ( - - {variable.key} - {environments.map((environment) => { - const value = variable.values[environment.id]?.value; - - if (!value) { - return Not set; + {groupedEnvironmentVariables.length > 0 ? ( + groupedEnvironmentVariables.map((variable) => { + const cellClassName = "py-2"; + let borderedCellClassName = ""; + + if (variable.occurences > 1) { + borderedCellClassName = + "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright group-hover/table-row:after:bg-grid-bright group-hover/table-row:before:bg-grid-bright"; + if (variable.isLastTime) { + borderedCellClassName = ""; + } else if (variable.isFirstTime) { + } + } else { + } + + return ( + + > + + {variable.isFirstTime ? ( + + ) : null} + + + {variable.isSecret ? ( + + + Secret + + } + content="This variable is secret and cannot be revealed." + /> + ) : ( - - ); - })} - - - - - } - > - - )) + )} + + + + + + + + + + } + /> + + ); + }) ) : ( - +
You haven't set any environment variables yet. []; revealAll: boolean; }) { - const [reveal, setReveal] = useState(revealAll); - const [isOpen, setIsOpen] = useState(false); const lastSubmission = useActionData(); const navigation = useNavigation(); - const hiddenValues = Object.values(variable.values).filter( - (value) => !environments.map((e) => e.id).includes(value.environment.id) - ); - const isLoading = navigation.state !== "idle" && navigation.formMethod === "post" && navigation.formData?.get("action") === "edit"; - const [form, { id }] = useForm({ + const [form, { id, environmentId, value }] = useForm({ id: "edit-environment-variable", // TODO: type this lastSubmission: lastSubmission as any, @@ -351,68 +418,36 @@ function EditEnvironmentVariablePanel({ - Edit {variable.key} + Edit environment variable
- - - {hiddenValues.map((value, index) => ( - - - - - ))} + + {id.error} + {environmentId.error}
- + - - {variable.key} - + -
-
+ -
- - setReveal(e.valueOf())} - /> -
-
- {environments.map((environment, index) => { - const value = variable.values[environment.id]?.value; - index += hiddenValues.length; - return ( - - - - - - ); - })} -
+ + +
+ + + + + {value.error} {form.error} @@ -463,6 +498,7 @@ function DeleteEnvironmentVariableButton({ + diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts index 07ae0e4823..2b63697990 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts @@ -123,7 +123,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const repository = new EnvironmentVariablesRepository(); - const variables = await repository.getEnvironment(environment.project.id, environment.id); + const variables = await repository.getEnvironmentWithRedactedSecrets( + environment.project.id, + environment.id + ); const environmentVariable = variables.find((v) => v.key === parsedParams.data.name); @@ -132,6 +135,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } return json({ + name: environmentVariable.key, value: environmentVariable.value, + isSecret: environmentVariable.isSecret, }); } diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index a22a2a1eaa..eae0e586e7 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -80,7 +80,16 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const repository = new EnvironmentVariablesRepository(); - const variables = await repository.getEnvironment(environment.project.id, environment.id); + const variables = await repository.getEnvironmentWithRedactedSecrets( + environment.project.id, + environment.id + ); - return json(variables.map((variable) => ({ name: variable.key, value: variable.value }))); + return json( + variables.map((variable) => ({ + name: variable.key, + value: variable.value, + isSecret: variable.isSecret, + })) + ); } diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 05970a4578..fdcbdc7e08 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -5,7 +5,7 @@ import { RuntimeEnvironmentType, } from "@trigger.dev/database"; import { z } from "zod"; -import { environmentTitle } from "~/components/environments/EnvironmentLabel"; +import { environmentFullTitle, environmentTitle } from "~/components/environments/EnvironmentLabel"; import { $transaction, prisma } from "~/db.server"; import { env } from "~/env.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; @@ -15,6 +15,7 @@ import { DeleteEnvironmentVariable, DeleteEnvironmentVariableValue, EnvironmentVariable, + EnvironmentVariableWithSecret, ProjectEnvironmentVariable, Repository, Result, @@ -51,6 +52,7 @@ export class EnvironmentVariablesRepository implements Repository { options: { override: boolean; environmentIds: string[]; + isSecret?: boolean; variables: { key: string; value: string; @@ -127,7 +129,7 @@ export class EnvironmentVariablesRepository implements Repository { variableErrors: existingVariableKeys.map((val) => ({ key: val.key, error: `Variable already set in ${val.environments - .map((e) => environmentTitle({ type: e })) + .map((e) => environmentFullTitle({ type: e })) .join(", ")}.`, })), }; @@ -187,8 +189,11 @@ export class EnvironmentVariablesRepository implements Repository { variableId: environmentVariable.id, environmentId: environmentId, valueReferenceId: secretReference.id, + isSecret: options.isSecret, + }, + update: { + isSecret: options.isSecret, }, - update: {}, }); await secretStore.setSecret<{ secret: string }>(key, { @@ -353,6 +358,85 @@ export class EnvironmentVariablesRepository implements Repository { } } + async editValue( + projectId: string, + options: { + id: string; + environmentId: string; + value: string; + } + ): Promise { + const project = await this.prismaClient.project.findFirst({ + where: { + id: projectId, + deletedAt: null, + }, + select: { + environments: { + select: { + id: true, + }, + }, + }, + }); + + if (!project) { + return { success: false as const, error: "Project not found" }; + } + + if (!project.environments.some((e) => e.id === options.environmentId)) { + return { success: false as const, error: "Environment not found" }; + } + + const environmentVariable = await this.prismaClient.environmentVariable.findFirst({ + select: { + id: true, + key: true, + values: { + where: { + environmentId: options.environmentId, + }, + select: { + valueReferenceId: true, + }, + }, + }, + where: { + id: options.id, + }, + }); + + if (!environmentVariable) { + return { success: false as const, error: "Environment variable not found" }; + } + + if (environmentVariable.values.length === 0) { + return { success: false as const, error: "Environment variable value not found" }; + } + + try { + await $transaction(this.prismaClient, "edit env var value", async (tx) => { + const secretStore = getSecretStore("DATABASE", { + prismaClient: tx, + }); + + const key = secretKey(projectId, options.environmentId, environmentVariable.key); + await secretStore.setSecret<{ secret: string }>(key, { + secret: options.value, + }); + }); + + return { + success: true as const, + }; + } catch (error) { + return { + success: false as const, + error: error instanceof Error ? error.message : "Something went wrong", + }; + } + } + async getProject(projectId: string): Promise { const project = await this.prismaClient.project.findFirst({ where: { @@ -426,6 +510,42 @@ export class EnvironmentVariablesRepository implements Repository { return results; } + async getEnvironmentWithRedactedSecrets( + projectId: string, + environmentId: string + ): Promise { + const variables = await this.getEnvironment(projectId, environmentId); + + // Get the keys of all secret variables + const secretValues = await this.prismaClient.environmentVariableValue.findMany({ + where: { + environmentId, + isSecret: true, + }, + select: { + variable: { + select: { + key: true, + }, + }, + }, + }); + const secretVarKeys = secretValues.map((r) => r.variable.key); + + // Filter out secret variables if includeSecrets is false + return variables.map((v) => { + if (secretVarKeys.includes(v.key)) { + return { + key: v.key, + value: "", + isSecret: true, + }; + } + + return { key: v.key, value: v.value, isSecret: false }; + }); + } + async getEnvironment(projectId: string, environmentId: string): Promise { const project = await this.prismaClient.project.findFirst({ where: { diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index ed5e3e70c2..521e22f7a2 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -47,6 +47,13 @@ export const DeleteEnvironmentVariableValue = z.object({ }); export type DeleteEnvironmentVariableValue = z.infer; +export const EditEnvironmentVariableValue = z.object({ + id: z.string(), + environmentId: z.string(), + value: z.string(), +}); +export type EditEnvironmentVariableValue = z.infer; + export type Result = | { success: true; @@ -72,11 +79,29 @@ export type EnvironmentVariable = { value: string; }; +export type EnvironmentVariableWithSecret = EnvironmentVariable & { + isSecret: boolean; +}; + export interface Repository { create(projectId: string, options: CreateEnvironmentVariables): Promise; edit(projectId: string, options: EditEnvironmentVariable): Promise; + editValue(projectId: string, options: EditEnvironmentVariableValue): Promise; getProject(projectId: string): Promise; + /** + * Get the environment variables for a given environment, it does NOT return values for secret variables + */ + getEnvironmentWithRedactedSecrets( + projectId: string, + environmentId: string + ): Promise; + /** + * Get the environment variables for a given environment + */ getEnvironment(projectId: string, environmentId: string): Promise; + /** + * Return all env vars, including secret variables with values. Should only be used for executing tasks. + */ getEnvironmentVariables(projectId: string, environmentId: string): Promise; delete(projectId: string, options: DeleteEnvironmentVariable): Promise; deleteValue(projectId: string, options: DeleteEnvironmentVariableValue): Promise; diff --git a/internal-packages/database/prisma/migrations/20250411172850_environment_variable_value_is_secret/migration.sql b/internal-packages/database/prisma/migrations/20250411172850_environment_variable_value_is_secret/migration.sql new file mode 100644 index 0000000000..44598a65b9 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250411172850_environment_variable_value_is_secret/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "EnvironmentVariableValue" +ADD COLUMN "isSecret" BOOLEAN NOT NULL DEFAULT false; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index b73a9f8e1f..e2b000406f 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2728,8 +2728,12 @@ model EnvironmentVariableValue { variableId String environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) environmentId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + + /// If true, the value is secret and cannot be revealed + isSecret Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([variableId, environmentId]) } diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index fd41554e5f..f86fef448f 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -17,6 +17,7 @@ import { DeletedScheduleObject, EnvironmentVariableResponseBody, EnvironmentVariableValue, + EnvironmentVariableWithSecret, EnvironmentVariables, ListQueueOptions, ListRunResponseItem, @@ -549,7 +550,7 @@ export class ApiClient { listEnvVars(projectRef: string, slug: string, requestOptions?: ZodFetchOptions) { return zodfetch( - EnvironmentVariables, + z.array(EnvironmentVariableWithSecret), `${this.baseUrl}/api/v1/projects/${projectRef}/envvars/${slug}`, { method: "GET", @@ -579,7 +580,7 @@ export class ApiClient { retrieveEnvVar(projectRef: string, slug: string, key: string, requestOptions?: ZodFetchOptions) { return zodfetch( - EnvironmentVariableValue, + EnvironmentVariableWithSecret, `${this.baseUrl}/api/v1/projects/${projectRef}/envvars/${slug}/${key}`, { method: "GET", diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 6f071ffa6d..608f6c3e83 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -806,11 +806,26 @@ export const EnvironmentVariable = z.object({ name: z.string(), value: z.string(), }); - export const EnvironmentVariables = z.array(EnvironmentVariable); export type EnvironmentVariables = z.infer; +export const EnvironmentVariableWithSecret = z.object({ + /** The name of the env var, e.g. `DATABASE_URL` */ + name: z.string(), + /** The value of the env var. If it's a secret, this will be a redacted value, not the real value. */ + value: z.string(), + /** + * Whether the env var is a secret or not. + * When you create env vars you can mark them as secrets. + * + * You can't view the value of a secret env var after setting it initially. + * For a secret env var, the value will be redacted. + */ + isSecret: z.boolean(), +}); +export type EnvironmentVariableWithSecret = z.infer; + export const UpdateMetadataRequestBody = FlushedRunMetadata; export type UpdateMetadataRequestBody = z.infer; diff --git a/packages/trigger-sdk/src/v3/envvars.ts b/packages/trigger-sdk/src/v3/envvars.ts index 90034ef2c1..9f0c64d180 100644 --- a/packages/trigger-sdk/src/v3/envvars.ts +++ b/packages/trigger-sdk/src/v3/envvars.ts @@ -4,6 +4,7 @@ import type { CreateEnvironmentVariableParams, EnvironmentVariableResponseBody, EnvironmentVariableValue, + EnvironmentVariableWithSecret, EnvironmentVariables, ImportEnvironmentVariablesParams, UpdateEnvironmentVariableParams, @@ -84,13 +85,15 @@ export function list( projectRef: string, slug: string, requestOptions?: ApiRequestOptions -): ApiPromise; -export function list(requestOptions?: ApiRequestOptions): ApiPromise; +): ApiPromise; +export function list( + requestOptions?: ApiRequestOptions +): ApiPromise; export function list( projectRefOrRequestOptions?: string | ApiRequestOptions, slug?: string, requestOptions?: ApiRequestOptions -): ApiPromise { +): ApiPromise { const $projectRef = !isRequestOptions(projectRefOrRequestOptions) ? projectRefOrRequestOptions : taskContext.ctx?.project.ref; @@ -188,17 +191,17 @@ export function retrieve( slug: string, name: string, requestOptions?: ApiRequestOptions -): ApiPromise; +): ApiPromise; export function retrieve( name: string, requestOptions?: ApiRequestOptions -): ApiPromise; +): ApiPromise; export function retrieve( projectRefOrName: string, slugOrRequestOptions?: string | ApiRequestOptions, name?: string, requestOptions?: ApiRequestOptions -): ApiPromise { +): ApiPromise { let $projectRef: string; let $slug: string; let $name: string; diff --git a/references/hello-world/src/trigger/envvars.ts b/references/hello-world/src/trigger/envvars.ts new file mode 100644 index 0000000000..3a6bd700d8 --- /dev/null +++ b/references/hello-world/src/trigger/envvars.ts @@ -0,0 +1,48 @@ +import { envvars, logger, task } from "@trigger.dev/sdk"; +import assert from "node:assert"; + +export const secretEnvVar = task({ + id: "secret-env-var", + retry: { + maxAttempts: 1, + }, + run: async (_, { ctx }) => { + logger.log("ctx", { ctx }); + + logger.log("process.env", process.env); + + //list them + const vars = await envvars.list(ctx.project.ref, ctx.environment.slug); + logger.log("envVars", { vars }); + + //get non secret env var + const nonSecretEnvVar = vars.find((v) => !v.isSecret); + assert.equal(nonSecretEnvVar?.isSecret, false); + assert.notEqual(nonSecretEnvVar?.value, ""); + + //retrieve the non secret env var + const retrievedNonSecret = await envvars.retrieve( + ctx.project.ref, + ctx.environment.slug, + nonSecretEnvVar!.name + ); + logger.log("retrievedNonSecret", { retrievedNonSecret }); + assert.equal(retrievedNonSecret?.isSecret, false); + assert.equal(retrievedNonSecret?.value, nonSecretEnvVar!.value); + + //get secret env var + const secretEnvVar = vars.find((v) => v.isSecret); + assert.equal(secretEnvVar?.isSecret, true); + assert.equal(secretEnvVar?.value, ""); + + //retrieve the secret env var + const retrievedSecret = await envvars.retrieve( + ctx.project.ref, + ctx.environment.slug, + secretEnvVar!.name + ); + logger.log("retrievedSecret", { retrievedSecret }); + assert.equal(retrievedSecret?.isSecret, true); + assert.equal(retrievedSecret?.value, ""); + }, +});