fix(dashboard): persist deployment env edits safely

This commit is contained in:
Hydra
2026-09-04 14:37:18 +03:00
parent 5d1c78e8a1
commit 062bb6d868
12 changed files with 649 additions and 333 deletions
@@ -8,7 +8,12 @@ import { getApiErrorMessage } from "@/lib/api/client";
import { useToast } from "@/context/ToastContext";
import { useI18n, interpolate } from "@/components/i18n-provider";
import { useProjectSettings } from "@/context/ProjectSettingsContext";
import { computeEnvDiff } from "./env-diff";
import type { EnvironmentVariable } from "@/components/import-project/types";
import {
computeProjectEnvDiff,
createProjectEnvEditState,
type PersistedProjectEnv,
} from "@/lib/project-env-diff";
/**
* Per-variable production env editor (modal). Safe by design:
@@ -20,17 +25,10 @@ import { computeEnvDiff } from "./env-diff";
const ENVIRONMENT = "production";
interface Row {
interface Row extends EnvironmentVariable {
/** Stable local id for React keys. */
uid: string;
key: string;
/** Current input value. For an untouched secret this stays "" (we never hold the real value). */
value: string;
isSecret: boolean;
/** The persisted key when this row was loaded (null for a freshly-added row). */
originalKey: string | null;
/** Was this loaded as a secret whose real value we don't have until re-entered? */
loadedSecret: boolean;
}
let uidCounter = 0;
@@ -50,9 +48,7 @@ export function EnvVarsEditor({
const { projectData } = useProjectSettings();
const hasActiveDeployment = Boolean(projectData?.activeDeploymentId);
const [rows, setRows] = useState<Row[]>([]);
// Keys that existed when the editor loaded — needed to detect deletions
// (a removed row is gone from `rows`, so its key must be remembered here).
const [originalKeys, setOriginalKeys] = useState<string[]>([]);
const [baseline, setBaseline] = useState<PersistedProjectEnv[] | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [reveal, setReveal] = useState<Record<string, boolean>>({});
@@ -69,20 +65,19 @@ export function EnvVarsEditor({
const load = useCallback(async () => {
setLoading(true);
setRows([]);
setBaseline(null);
setLinked({});
try {
const res = await projectsApi.getEnv(projectId);
const loaded: Row[] = (res?.data ?? [])
.filter((v) => v.environment === ENVIRONMENT)
.map((v) => ({
uid: nextUid(),
key: v.key,
value: v.isSecret ? "" : v.value, // never seed the input with the mask
isSecret: v.isSecret,
originalKey: v.key,
loadedSecret: v.isSecret,
}));
const envState = createProjectEnvEditState(res?.data ?? [], ENVIRONMENT);
const loaded: Row[] = envState.rows.map((row) => ({
...row,
uid: nextUid(),
isSecret: row.isSecret ?? false,
}));
setRows(loaded);
setOriginalKeys(loaded.map((r) => r.key));
setBaseline(envState.baseline);
// Best-effort: which of these keys are managed by a service connection?
const conns = await connectionsApi.list(projectId).catch(() => ({ data: [] }));
const map: Record<string, ProjectConnection> = {};
@@ -109,13 +104,14 @@ export function EnvVarsEditor({
const addRow = () =>
setRows((prev) => [
...prev,
{ uid: nextUid(), key: "", value: "", isSecret: false, originalKey: null, loadedSecret: false },
{ uid: nextUid(), key: "", value: "", visible: true, isSecret: false },
]);
const removeRow = (uid: string) => setRows((prev) => prev.filter((r) => r.uid !== uid));
const handleSave = async () => {
const result = computeEnvDiff(rows.map((r) => ({ ...r, key: r.key.trim() })), originalKeys);
if (baseline === null) return;
const result = computeProjectEnvDiff(rows, baseline);
if (!result.ok) {
showToast(result.error, "error", t.projectSettings.envVars.toast.validationTitle);
return;
@@ -222,7 +218,7 @@ export function EnvVarsEditor({
<button
type="button"
onClick={handleSave}
disabled={saving || loading}
disabled={saving || loading || baseline === null}
className="inline-flex items-center gap-1.5 rounded-lg bg-foreground px-3.5 py-2 text-sm font-medium text-background transition-colors hover:bg-foreground/90 disabled:opacity-50"
>
{saving ? <Loader2 className="size-3.5 animate-spin" /> : null}
@@ -294,7 +290,7 @@ export function EnvVarsEditor({
</button>
);
}
const showValue = reveal[r.uid] || (!r.isSecret && !r.loadedSecret);
const showValue = reveal[r.uid] || (!r.isSecret && !r.preserveValue);
return (
<div key={r.uid} className="flex items-center gap-2">
<input
@@ -308,8 +304,8 @@ export function EnvVarsEditor({
<input
type={showValue ? "text" : "password"}
value={r.value}
onChange={(e) => update(r.uid, { value: e.target.value })}
placeholder={r.loadedSecret ? t.projectSettings.envVars.secretPlaceholder : t.projectSettings.envVars.valuePlaceholder}
onChange={(e) => update(r.uid, { value: e.target.value, preserveValue: false })}
placeholder={r.preserveValue ? t.projectSettings.envVars.secretPlaceholder : t.projectSettings.envVars.valuePlaceholder}
spellCheck={false}
className="h-9 w-full rounded-lg border border-border/50 bg-muted/20 px-3 pe-9 font-mono text-[13px] text-foreground outline-none transition-colors focus:border-primary/40"
/>
@@ -1,103 +0,0 @@
import { describe, it, expect } from "vitest";
import { computeEnvDiff, type EnvEditorRow } from "./env-diff";
// Helpers to build rows tersely.
const loaded = (key: string, opts: Partial<EnvEditorRow> = {}): EnvEditorRow => ({
key,
value: "",
isSecret: false,
originalKey: key,
loadedSecret: false,
...opts,
});
const loadedSecret = (key: string, opts: Partial<EnvEditorRow> = {}): EnvEditorRow =>
loaded(key, { isSecret: true, loadedSecret: true, value: "", ...opts });
const added = (key: string, value: string, isSecret = false): EnvEditorRow => ({
key,
value,
isSecret,
originalKey: null,
loadedSecret: false,
});
function ok(rows: EnvEditorRow[], originalKeys: string[]) {
const r = computeEnvDiff(rows, originalKeys);
if (!r.ok) throw new Error(`expected ok, got error: ${r.error}`);
return r.diff;
}
describe("computeEnvDiff", () => {
it("untouched masked secret → never in upserts or deletes (no corruption, no wipe)", () => {
const diff = ok([loadedSecret("API_KEY")], ["API_KEY"]);
expect(diff.upserts).toEqual([]); // the masked secret value is never re-sent
expect(diff.deletes).toEqual([]);
});
it("a non-secret re-upserts idempotently (same value) — safe, never a wipe", () => {
// We don't track original non-secret values, so any present value re-writes
// the same value. Harmless; the critical guarantee is that SECRETS stay out.
const diff = ok([loaded("PUBLIC", { value: "same" })], ["PUBLIC"]);
expect(diff.upserts).toEqual([{ key: "PUBLIC", value: "same", isSecret: false }]);
expect(diff.deletes).toEqual([]);
});
it("editing a non-secret value → upsert that key only", () => {
const diff = ok([loaded("PUBLIC", { value: "new" }), loadedSecret("API_KEY")], ["PUBLIC", "API_KEY"]);
expect(diff.upserts).toEqual([{ key: "PUBLIC", value: "new", isSecret: false }]);
expect(diff.deletes).toEqual([]);
});
it("re-entering a secret → upsert with the new value (untouched ones stay out)", () => {
const diff = ok([loadedSecret("API_KEY", { value: "new-secret" })], ["API_KEY"]);
expect(diff.upserts).toEqual([{ key: "API_KEY", value: "new-secret", isSecret: true }]);
expect(diff.deletes).toEqual([]);
});
it("deleting a var (row removed from the editor) → key appears in deletes", () => {
// Loaded with KEEP + GONE; user removed GONE so only KEEP remains in rows.
const diff = ok([loaded("KEEP", { value: "v" })], ["KEEP", "GONE"]);
expect(diff.deletes).toEqual(["GONE"]);
// KEEP is a non-secret with its (unchanged) value present → idempotent upsert.
expect(diff.upserts).toEqual([{ key: "KEEP", value: "v", isSecret: false }]);
});
it("renaming a non-secret → delete old key + upsert new key", () => {
const diff = ok([loaded("OLD", { key: "NEW", value: "v" })], ["OLD"]);
expect(diff.upserts).toEqual([{ key: "NEW", value: "v", isSecret: false }]);
expect(diff.deletes).toEqual(["OLD"]);
});
it("adding a new var → upsert", () => {
const diff = ok([added("NEW", "v", true)], []);
expect(diff.upserts).toEqual([{ key: "NEW", value: "v", isSecret: true }]);
expect(diff.deletes).toEqual([]);
});
it("blank freshly-added row is ignored", () => {
const diff = ok([added("", ""), loaded("PUBLIC", { value: "x" })], ["PUBLIC"]);
expect(diff.upserts).toEqual([{ key: "PUBLIC", value: "x", isSecret: false }]);
expect(diff.deletes).toEqual([]);
});
it("rejects a new var with no value", () => {
expect(computeEnvDiff([added("NEW", "")], [])).toMatchObject({ ok: false });
});
it("rejects duplicate keys", () => {
expect(computeEnvDiff([loaded("DUP", { value: "a" }), added("DUP", "b")], ["DUP"])).toMatchObject({
ok: false,
});
});
it("rejects renaming a masked secret without re-entering its value", () => {
expect(computeEnvDiff([loadedSecret("API_KEY", { key: "RENAMED" })], ["API_KEY"])).toMatchObject({
ok: false,
});
});
it("rejects flipping a masked secret to non-secret without re-entering", () => {
expect(computeEnvDiff([loadedSecret("API_KEY", { isSecret: false })], ["API_KEY"])).toMatchObject({
ok: false,
});
});
});
@@ -1,88 +0,0 @@
/**
* Pure diff computation for the per-variable env editor. Kept framework-free so
* the data-loss-critical logic (never re-send an untouched masked secret; only
* touch keys the user actually changed/added/removed) is unit-testable.
*/
export interface EnvEditorRow {
/** Current key (callers pass it already trimmed). */
key: string;
/** Current input value. "" for an untouched secret (we never hold the real value). */
value: string;
isSecret: boolean;
/** The persisted key when loaded (null for a freshly-added row). */
originalKey: string | null;
/** Was this loaded as a secret whose real value we don't have until re-entered? */
loadedSecret: boolean;
}
export interface EnvDiff {
upserts: { key: string; value: string; isSecret: boolean }[];
deletes: string[];
}
export type EnvDiffResult = { ok: true; diff: EnvDiff } | { ok: false; error: string };
/**
* Compute the merge diff (upserts + deletes) from the editor's CURRENT rows and
* the set of keys that existed when the editor loaded (`originalKeys`). The
* original set is required because a deleted var is removed from `rows`
* entirely — without it we could never tell a deletion from "never existed".
*
* Returns a validation error instead of a diff when the intent can't be
* satisfied safely (e.g. renaming a masked secret without re-entering its
* value — we don't have the value to carry over).
*/
export function computeEnvDiff(rows: EnvEditorRow[], originalKeys: string[]): EnvDiffResult {
const seen = new Set<string>();
const upserts: EnvDiff["upserts"] = [];
for (const r of rows) {
// Ignore a completely blank freshly-added row.
if (!r.key && !r.value && r.originalKey === null) continue;
if (!r.key) return { ok: false, error: "Every variable needs a name" };
if (seen.has(r.key)) return { ok: false, error: `Duplicate key "${r.key}"` };
seen.add(r.key);
const renamed = r.originalKey !== null && r.key !== r.originalKey;
const userEnteredValue = r.value !== "";
if (r.originalKey === null) {
// New var — must carry a value.
if (!userEnteredValue) return { ok: false, error: `"${r.key}" needs a value` };
upserts.push({ key: r.key, value: r.value, isSecret: r.isSecret });
continue;
}
if (r.loadedSecret && !userEnteredValue) {
// Untouched masked secret: we don't have its value. Renaming or flipping
// its secret flag would lose the value, so require a re-entry; otherwise
// leave it entirely alone (not in the diff).
if (renamed || r.isSecret !== true) {
return { ok: false, error: `Re-enter the value for "${r.originalKey}" to change it` };
}
continue;
}
if (renamed || userEnteredValue) {
upserts.push({ key: r.key, value: r.value, isSecret: r.isSecret });
}
}
// Deletes: any key that existed at load but is no longer present under its
// OWN name now — covers BOTH removed rows (absent from `rows`) and the old
// name of a renamed row. Computed against `originalKeys`, not `rows`, so a
// deleted row (which is gone from `rows`) is still caught.
const keptOriginalKeys = new Set(
rows
.filter((r) => r.originalKey !== null && r.key === r.originalKey)
.map((r) => r.originalKey as string),
);
const upsertKeys = new Set(upserts.map((u) => u.key));
const deletes = Array.from(new Set(originalKeys))
.filter((k) => !keptOriginalKeys.has(k))
.filter((k) => !upsertKeys.has(k));
return { ok: true, diff: { upserts, deletes } };
}
@@ -21,20 +21,15 @@ import { useOptionalDeployment } from "@/context/DeploymentContext";
import { useToast } from "@/context/ToastContext";
import { useI18n, interpolate } from "@/components/i18n-provider";
import type { Dictionary } from "@/i18n";
import type { EnvironmentVariable } from "./types";
// #336: env values arrive masked as ENV_MASK (shared with the API via @repo/core
// so the exact sentinel can't drift). A masked row keeps the sentinel in state —
// a save round-trips it and the backend restores the stored secret; "show
// values" reveals real values into a display-only overlay; editing a revealed
// row replaces the sentinel with the typed value.
type EnvironmentVariableRow = {
type EnvironmentVariableRow = EnvironmentVariable & {
sourceId?: string;
key: string;
value: string;
visible: boolean;
/** Saved secret whose plaintext is intentionally absent from local state. */
preserveValue?: boolean;
isSecret?: boolean;
};
type EnvironmentVariableMeta = {
@@ -344,18 +339,12 @@ const EnvironmentVariables: React.FC<EnvironmentVariablesPropsOptional> = ({
}, [allShown, maskedKeys, hideKeys, revealAndShow]);
const handleKeyChange = (index: number, value: string) => {
const row = currentEnvVars[index];
// Keep preserveValue until the VALUE is actually edited. The env planner
// can then reject a masked-secret rename/classification change instead of
// mistaking the blank display value for the user's intent.
updateEnvVars(
currentEnvVars.map((env, i) =>
i === index
? {
...env,
key: value,
// A stored value belongs to its original key. Renaming the row
// cannot carry that secret to a different variable implicitly.
preserveValue: row?.key === value ? row.preserveValue : false,
}
: env,
i === index ? { ...env, key: value } : env,
),
);
};
@@ -1,4 +1,5 @@
import type { StackId } from "@repo/core";
import type { EditableProjectEnvRow } from "@/lib/project-env-diff";
export interface Framework {
id: string;
@@ -13,19 +14,7 @@ export interface RepoData {
private: boolean | false;
}
export interface EnvironmentVariable {
key: string;
value: string;
visible: boolean;
/**
* The row's plaintext was intentionally not loaded (a saved secret or a
* source-scanned value). Deploy serialization sends the mask sentinel so the
* API can recover it from its trusted store/source. Any edit clears this.
*/
preserveValue?: boolean;
/** Explicit secret flag: true = secret (masked on read), false = non-secret. */
isSecret?: boolean;
}
export type EnvironmentVariable = EditableProjectEnvRow;
export type StartCommand = string;
@@ -1,15 +1,25 @@
import { describe, expect, it } from "vitest";
import { ENV_MASK } from "@repo/core";
import {
deploymentEnvPayload,
mergePreparedSourceEnv,
savedDeploymentEnvRows,
} from "./env-payload";
import type { EnvironmentVariable } from "@/components/import-project/types";
import type { PersistedProjectEnv } from "@/lib/project-env-diff";
import { mergePreparedSourceEnv, planDeploymentEnvPersistence } from "./env-payload";
const row = (
key: string,
value: string,
options: Partial<EnvironmentVariable> = {},
): EnvironmentVariable => ({ key, value, visible: true, ...options });
const saved = (key: string, value: string, isSecret = false): PersistedProjectEnv => ({
key,
value,
isSecret,
});
describe("mergePreparedSourceEnv", () => {
it("activates openship.json keys, keeps .env opt-in, and preserves operator overrides", () => {
const result = mergePreparedSourceEnv(
[{ key: "SHARED", value: "operator", visible: true }],
[row("SHARED", "operator")],
{
DECLARED: ENV_MASK,
SHARED: ENV_MASK,
@@ -19,45 +29,105 @@ describe("mergePreparedSourceEnv", () => {
);
expect(result.envVars).toEqual([
{ key: "SHARED", value: "operator", visible: true },
{ key: "DECLARED", value: ENV_MASK, visible: true, preserveValue: true },
row("SHARED", "operator"),
row("DECLARED", ENV_MASK, { preserveValue: true }),
]);
expect(result.rootEnvVars).toEqual([
{ key: "DOT_ENV_ONLY", value: ENV_MASK, visible: true, preserveValue: true },
]);
expect(deploymentEnvPayload(result.envVars)).toEqual({
SHARED: "operator",
DECLARED: ENV_MASK,
});
expect(result.rootEnvVars).toEqual([row("DOT_ENV_ONLY", ENV_MASK, { preserveValue: true })]);
});
});
describe("deploymentEnvPayload", () => {
it("marks only saved production secrets for preservation", () => {
expect(
savedDeploymentEnvRows([
{ key: "AUTH_SECRET", value: ENV_MASK, isSecret: true, environment: "production" },
{ key: "PUBLIC_SETTING", value: "enabled", isSecret: false, environment: "production" },
{ key: "PREVIEW_ONLY", value: "preview", isSecret: false, environment: "preview" },
]),
).toEqual([
{ key: "AUTH_SECRET", value: "", visible: true, preserveValue: true },
{ key: "PUBLIC_SETTING", value: "enabled", visible: true, preserveValue: undefined },
]);
});
describe("planDeploymentEnvPersistence", () => {
it("sends entered and source-owned values through build/access for a new project", () => {
const result = planDeploymentEnvPersistence({
envVars: [
row("PUBLIC_SETTING", "enabled"),
row("OPTIONAL", ""),
row("DECLARED_BY_SOURCE", ENV_MASK, { preserveValue: true }),
],
baseline: null,
});
it("#801: sends a preserve sentinel for an unreadable saved secret", () => {
expect(
deploymentEnvPayload([
{ key: "AUTH_SECRET", value: "", visible: false, preserveValue: true },
{ key: "PUBLIC_SETTING", value: "enabled", visible: true },
]),
).toEqual({ AUTH_SECRET: ENV_MASK, PUBLIC_SETTING: "enabled" });
});
it("keeps an explicitly entered empty value distinct from a preserved secret", () => {
expect(deploymentEnvPayload([{ key: "OPTIONAL", value: "", visible: true }])).toEqual({
OPTIONAL: "",
expect(result).toEqual({
ok: true,
merge: null,
buildAccessEnvVars: {
PUBLIC_SETTING: "enabled",
OPTIONAL: "",
DECLARED_BY_SOURCE: ENV_MASK,
},
});
});
it("plans a partial merge and omits the full build/access payload for an existing project", () => {
const result = planDeploymentEnvPersistence({
projectId: "project-1",
envVars: [
row("AUTH_SECRET", "replacement", {
originalKey: "AUTH_SECRET",
isSecret: true,
}),
row("PUBLIC_SETTING", "enabled", {
originalKey: "PUBLIC_SETTING",
isSecret: false,
}),
],
baseline: [saved("AUTH_SECRET", ENV_MASK, true), saved("PUBLIC_SETTING", "enabled")],
});
expect(result).toEqual({
ok: true,
merge: {
upserts: [{ key: "AUTH_SECRET", value: "replacement", isSecret: true }],
deletes: [],
},
buildAccessEnvVars: undefined,
});
});
it("omits an untouched saved secret from the existing-project merge", () => {
const result = planDeploymentEnvPersistence({
projectId: "project-1",
envVars: [
row("AUTH_SECRET", "", {
originalKey: "AUTH_SECRET",
preserveValue: true,
isSecret: true,
}),
],
baseline: [saved("AUTH_SECRET", ENV_MASK, true)],
});
expect(result).toEqual({
ok: true,
merge: { upserts: [], deletes: [] },
buildAccessEnvVars: undefined,
});
});
it("leaves untracked preserved source rows to the server-side source resolver", () => {
const result = planDeploymentEnvPersistence({
projectId: "project-1",
envVars: [
row("SAVED", "same", { originalKey: "SAVED", isSecret: false }),
row("SOURCE_DEFAULT", ENV_MASK, { preserveValue: true }),
],
baseline: [saved("SAVED", "same")],
});
expect(result).toEqual({
ok: true,
merge: { upserts: [], deletes: [] },
buildAccessEnvVars: undefined,
});
});
it("fails closed when an existing project's environment was not loaded", () => {
const result = planDeploymentEnvPersistence({
projectId: "project-1",
envVars: [],
baseline: null,
});
expect(result).toMatchObject({ ok: false });
});
});
@@ -1,5 +1,11 @@
import { ENV_MASK, isMaskedValue } from "@repo/core";
import { isMaskedValue } from "@repo/core";
import type { EnvironmentVariable } from "@/components/import-project/types";
import {
computeProjectEnvDiff,
serializeNewProjectEnv,
type PersistedProjectEnv,
type ProjectEnvDiff,
} from "@/lib/project-env-diff";
function sourceEnvRows(env?: Record<string, string>): EnvironmentVariable[] {
return Object.entries(env ?? {}).map(([key, value]) => ({
@@ -33,33 +39,52 @@ export function mergePreparedSourceEnv(
};
}
/** Keep an unreadable API secret distinct from an explicitly empty value. */
export function savedDeploymentEnvRows(
rows: Array<{ key: string; value: string; isSecret: boolean; environment: string }>,
): EnvironmentVariable[] {
return rows
.filter((row) => row.environment === "production")
.map((row) => ({
key: row.key,
value: row.isSecret ? "" : row.value,
visible: true,
preserveValue: row.isSecret || undefined,
}));
}
export type DeploymentEnvPersistencePlan =
| { ok: false; error: string }
| {
ok: true;
/** Existing projects apply this partial merge before save/deploy. */
merge: ProjectEnvDiff | null;
/** Only a new project sends env through build/access. */
buildAccessEnvVars: Record<string, string> | undefined;
};
/**
* Serialize the wizard's project env rows without turning unreadable saved
* secrets into empty strings. The mask is a transport sentinel; the API
* resolves it against the stored encrypted project row before snapshotting.
* Decide which persistence path owns deployment-wizard env changes.
*
* A new project has no env store yet, so build/access receives its values and
* persists them. An existing project is patched through the same partial merge
* contract as the dedicated env editor, then build/access reads that store.
* Requiring a loaded baseline for existing projects prevents a failed env read
* from being mistaken for an empty environment and deleting data.
*/
export function deploymentEnvPayload(
envVars: EnvironmentVariable[] | null | undefined,
): Record<string, string> | undefined {
const result: Record<string, string> = {};
for (const envVar of envVars ?? []) {
const key = envVar.key.trim();
if (!key) continue;
result[key] = envVar.preserveValue ? ENV_MASK : envVar.value;
export function planDeploymentEnvPersistence({
projectId,
envVars,
baseline,
}: {
projectId?: string;
envVars: readonly EnvironmentVariable[];
baseline: readonly PersistedProjectEnv[] | null;
}): DeploymentEnvPersistencePlan {
if (!projectId) {
const serialized = serializeNewProjectEnv(envVars);
return serialized.ok
? { ok: true, merge: null, buildAccessEnvVars: serialized.envVars }
: serialized;
}
return Object.keys(result).length > 0 ? result : undefined;
if (baseline === null) {
return {
ok: false,
error: "Project environment was not loaded. Reload the page and try again.",
};
}
const result = computeProjectEnvDiff(envVars, baseline, {
// openship.json values are owned and resolved by the server-side source
// scan. They intentionally have no originalKey/project-env baseline row.
ignoreUntrackedPreserved: true,
});
return result.ok ? { ok: true, merge: result.diff, buildAccessEnvVars: undefined } : result;
}
@@ -17,6 +17,7 @@ import {
} from "@repo/core";
import type { BuildLog } from "@/utils/deploymentPhaseDetector";
import type { BuildSessionLoadResult } from "./load-session";
import type { PersistedProjectEnv } from "@/lib/project-env-diff";
import { randomUUID } from "@/lib/random-uuid";
// ─── Monorepo sub-app ────────────────────────────────────────────────────────
@@ -399,6 +400,12 @@ export interface DeploymentConfig {
buildImage: string;
publicEndpoints: PublicEndpoint[];
envVars: EnvironmentVariable[];
/**
* Authoritative production-env snapshot used to persist only the wizard's
* changes. `null` means an existing project's env was never loaded, which is
* intentionally different from a project with no saved variables.
*/
projectEnvBaseline: PersistedProjectEnv[] | null;
/** Root .env values detected during prepare; user must import before they apply.
* Explicit openship.json env is placed directly in envVars instead. */
rootEnvVars: EnvironmentVariable[];
@@ -503,6 +510,7 @@ export const DEFAULT_CONFIG: DeploymentConfig = {
workloadType: "web",
},
envVars: [],
projectEnvBaseline: null,
rootEnvVars: [],
};
@@ -18,7 +18,8 @@ import { DeployCredentialModal } from "@/components/deployments/DeployCredential
import { useServerGitHubConnectModal } from "@/components/github/ServerGitHubConnect";
import type { DeploymentConfig, DeploymentState, DeploymentStatus, ServiceDeployStatus } from "./types";
import { syncActiveModeSnapshot } from "./mode-config";
import { deploymentEnvPayload } from "./env-payload";
import { planDeploymentEnvPersistence } from "./env-payload";
import type { ProjectEnvDiff } from "@/lib/project-env-diff";
import {
BUILD_PHASES,
DEFAULT_CONFIG,
@@ -45,6 +46,14 @@ const ERROR_DEBOUNCE_MS = 1000;
const MAX_RENDERED_BUILD_LOGS = 2000;
const BUILD_STATUS_POLL_MS = 3000;
async function persistProjectEnvDiff(projectId: string, diff: ProjectEnvDiff | null) {
if (!diff || (diff.upserts.length === 0 && diff.deletes.length === 0)) return;
await projectsApi.mergeEnv(projectId, {
environment: "production",
...diff,
});
}
// Map a getBuildStatus snapshot's per-service rows into UI service statuses.
// Shared by the initial hydrate (loadBuildSession) and the self-heal poll so
// both derive serviceStatuses identically. DB may store running/failed/pending/
@@ -658,6 +667,16 @@ export function useDeploymentBuild(
return null;
}
const envPlan = planDeploymentEnvPersistence({
projectId: config.projectId,
envVars: config.envVars,
baseline: config.projectEnvBaseline,
});
if (!envPlan.ok) {
showToast(envPlan.error, "error", "Environment variables");
return null;
}
lastErrorRef.current = null;
const localBuildStartedAt = new Date().toISOString();
@@ -703,11 +722,11 @@ export function useDeploymentBuild(
try {
// ── Save-only (Edit from the Runtime page): the project ALREADY exists,
// so persist build + runtime config in ONE atomic call (POST /:id/options)
// and STOP. Deliberately does NOT call `ensure` (which would resend git +
// publicEndpoints + a re-detected framework and clobber live config/routes)
// and does NOT touch env (env has its own per-variable editor — a blind
// replace here would wipe/corrupt masked secrets). No deploy. ────────────
// so persist build + runtime config through POST /:id/options and STOP.
// Deliberately does NOT call `ensure` (which would resend git + routes + a
// re-detected framework). Env uses the shared per-key merge contract:
// untouched masked secrets are omitted and explicit edits are persisted
// before success is reported. No deploy. ────────────────────────────────
if (saveConfigOnly) {
const projectId = config.projectId;
if (!projectId) {
@@ -739,6 +758,7 @@ export function useDeploymentBuild(
? { runtimeMode: config.runtimeMode }
: {}),
});
await persistProjectEnvDiff(projectId, envPlan.merge);
showToast("Configuration saved", "success", "Saved");
return projectId;
} catch (err) {
@@ -841,15 +861,19 @@ export function useDeploymentBuild(
// errors but the project row already exists at this point.
ensuredProjectId = projectData.project_id;
// Step 2: Create deployment with config snapshot + env vars
const envVarsMap = deploymentEnvPayload(config.envVars);
// Existing-project env is authoritative in its project store. Apply only
// the editor diff before build/access; omitting its envVars payload avoids
// the endpoint's legacy full-replace behavior. New projects still send
// their initial values through build/access, which creates that store.
await persistProjectEnvDiff(projectData.project_id, envPlan.merge);
// Step 2: Create deployment with config snapshot + env vars
const data = await deployApi.buildAccess({
projectId: projectData.project_id,
branch: config.branch || undefined,
// Folder-upload: adopt the uploaded source (workspace or staging dir).
uploadSessionId: config.uploadSessionId || undefined,
envVars: envVarsMap,
envVars: envPlan.buildAccessEnvVars,
// "None" routing → explicit [] (no public URL). Must be [], not
// undefined: undefined makes the backend auto-derive a free subdomain.
publicEndpoints: !isServiceDeployment
@@ -39,7 +39,8 @@ import {
syncPublicEndpointState,
} from "./types";
import { buildSingleModeSnapshot, syncActiveModeSnapshot } from "./mode-config";
import { mergePreparedSourceEnv, savedDeploymentEnvRows } from "./env-payload";
import { mergePreparedSourceEnv } from "./env-payload";
import { createProjectEnvEditState } from "@/lib/project-env-diff";
import { normalizeSubdomain } from "@/utils/subdomain";
import { useDefaultDomainType } from "@/context/CloudContext";
@@ -1239,11 +1240,11 @@ export function useDeploymentConfig() {
const svcRes = await servicesApi.list(projectId).catch(() => null);
const serviceRows: Service[] = svcRes?.services ?? [];
// Production env → config.envVars. Secret plaintext never enters this
// context; preserveValue keeps its display blank distinct from a real
// empty value when the deployment request is serialized (#801).
const envRes = await projectsApi.getEnv(projectId).catch(() => null);
const envVars: DeploymentConfig["envVars"] = savedDeploymentEnvRows(envRes?.data ?? []);
// Load editable production env and its immutable baseline as one unit.
// This read is data-loss-sensitive: fail the whole initialization if it
// fails, rather than treating an unknown environment as an empty one.
const envRes = await projectsApi.getEnv(projectId);
const envState = createProjectEnvEditState(envRes?.data ?? []);
const response = buildSavedProjectResponse(project, serviceRows);
const repoName = project.gitRepo || project.name || "project";
@@ -1281,7 +1282,10 @@ export function useDeploymentConfig() {
return {
...prev,
projectId,
envVars: envVars.length ? envVars : prev.envVars,
// The successful env read is authoritative even when empty. Keeping
// stale rows here would turn a later save into unintended upserts.
envVars: envState.rows,
projectEnvBaseline: envState.baseline,
...(savedTarget
? {
deployTarget: savedTarget,
@@ -1308,7 +1312,8 @@ export function useDeploymentConfig() {
}),
// buildPreparedConfig (shared with detection) doesn't load production
// env — overlay the saved values we fetched above.
envVars,
envVars: envState.rows,
projectEnvBaseline: envState.baseline,
// Repo-less catalog app: deploys from its saved service rows with no
// git source (the deploy guards treat this like local/upload).
isApp: Boolean((project as { isApp?: boolean }).isApp),
@@ -0,0 +1,189 @@
import { describe, expect, it } from "vitest";
import { ENV_MASK } from "@repo/core";
import type { EnvironmentVariable } from "@/components/import-project/types";
import {
computeProjectEnvDiff,
createProjectEnvEditState,
type PersistedProjectEnv,
} from "./project-env-diff";
const saved = (key: string, value: string, isSecret = false): PersistedProjectEnv => ({
key,
value,
isSecret,
});
const row = (
key: string,
value: string,
options: Partial<EnvironmentVariable> = {},
): EnvironmentVariable => ({ key, value, visible: true, ...options });
const loaded = (
key: string,
value: string,
options: Partial<EnvironmentVariable> = {},
): EnvironmentVariable => row(key, value, { originalKey: key, isSecret: false, ...options });
const secret = (key: string, options: Partial<EnvironmentVariable> = {}): EnvironmentVariable =>
loaded(key, "", { preserveValue: true, isSecret: true, visible: false, ...options });
function diff(rows: EnvironmentVariable[], baseline: PersistedProjectEnv[]) {
const result = computeProjectEnvDiff(rows, baseline);
if (!result.ok) throw new Error(`Expected a diff, got: ${result.error}`);
return result.diff;
}
describe("createProjectEnvEditState", () => {
it("builds editable rows and the comparison baseline without exposing secret plaintext", () => {
expect(
createProjectEnvEditState([
{
key: "AUTH_SECRET",
value: ENV_MASK,
isSecret: true,
environment: "production",
},
{
key: "PUBLIC_SETTING",
value: "enabled",
isSecret: false,
environment: "production",
},
{
key: "PREVIEW_ONLY",
value: "preview",
isSecret: false,
environment: "preview",
},
]),
).toEqual({
rows: [
row("AUTH_SECRET", "", {
originalKey: "AUTH_SECRET",
preserveValue: true,
isSecret: true,
}),
row("PUBLIC_SETTING", "enabled", {
originalKey: "PUBLIC_SETTING",
preserveValue: undefined,
isSecret: false,
}),
],
baseline: [saved("AUTH_SECRET", ENV_MASK, true), saved("PUBLIC_SETTING", "enabled")],
});
});
});
describe("computeProjectEnvDiff", () => {
it("does not write an untouched masked secret", () => {
expect(diff([secret("AUTH_SECRET")], [saved("AUTH_SECRET", ENV_MASK, true)])).toEqual({
upserts: [],
deletes: [],
});
});
it("upserts a typed secret replacement while preserving its classification", () => {
expect(
diff(
[loaded("AUTH_SECRET", "replacement", { isSecret: true })],
[saved("AUTH_SECRET", ENV_MASK, true)],
),
).toEqual({
upserts: [{ key: "AUTH_SECRET", value: "replacement", isSecret: true }],
deletes: [],
});
});
it("distinguishes explicitly clearing a secret from leaving its blank display untouched", () => {
expect(
diff(
[loaded("AUTH_SECRET", "", { preserveValue: false, isSecret: true })],
[saved("AUTH_SECRET", ENV_MASK, true)],
),
).toEqual({
upserts: [{ key: "AUTH_SECRET", value: "", isSecret: true }],
deletes: [],
});
});
it("does not rewrite unchanged plaintext", () => {
expect(diff([loaded("PUBLIC_SETTING", "same")], [saved("PUBLIC_SETTING", "same")])).toEqual({
upserts: [],
deletes: [],
});
});
it("renames plaintext with one upsert and one old-key delete", () => {
expect(
diff(
[
row("NEW_NAME", "value", {
originalKey: "OLD_NAME",
isSecret: false,
}),
],
[saved("OLD_NAME", "value")],
),
).toEqual({
upserts: [{ key: "NEW_NAME", value: "value", isSecret: false }],
deletes: ["OLD_NAME"],
});
});
it("deletes a persisted row removed from the editor", () => {
expect(diff([], [saved("REMOVED", "value")])).toEqual({
upserts: [],
deletes: ["REMOVED"],
});
});
it("accepts an explicitly empty new value and ignores a completely blank row", () => {
expect(diff([row("OPTIONAL", ""), row("", "")], [])).toEqual({
upserts: [{ key: "OPTIONAL", value: "", isSecret: false }],
deletes: [],
});
});
it("rejects duplicate current keys", () => {
expect(computeProjectEnvDiff([row("DUP", "one"), row(" DUP ", "two")], [])).toMatchObject({
ok: false,
});
});
it("rejects renaming a masked secret without re-entering its value", () => {
expect(
computeProjectEnvDiff(
[secret("AUTH_SECRET", { key: "RENAMED" })],
[saved("AUTH_SECRET", ENV_MASK, true)],
),
).toMatchObject({ ok: false });
});
it("rejects changing a masked secret's classification without re-entering its value", () => {
expect(
computeProjectEnvDiff(
[secret("AUTH_SECRET", { isSecret: false })],
[saved("AUTH_SECRET", ENV_MASK, true)],
),
).toMatchObject({ ok: false });
});
it("never emits the replacement key in both upserts and deletes", () => {
expect(
diff([row("SAME_KEY", "replacement", { isSecret: true })], [saved("SAME_KEY", "old")]),
).toEqual({
upserts: [{ key: "SAME_KEY", value: "replacement", isSecret: true }],
deletes: [],
});
});
it("rejects an untracked preserved row unless its source has explicit ownership", () => {
const rows = [row("SOURCE_VALUE", ENV_MASK, { preserveValue: true })];
expect(computeProjectEnvDiff(rows, [])).toMatchObject({ ok: false });
expect(computeProjectEnvDiff(rows, [], { ignoreUntrackedPreserved: true })).toEqual({
ok: true,
diff: { upserts: [], deletes: [] },
});
});
});
+212
View File
@@ -0,0 +1,212 @@
import { ENV_MASK, isMaskedValue, looksLikeSecretKey } from "@repo/core";
export interface EditableProjectEnvRow {
key: string;
value: string;
visible: boolean;
/** Persisted key this editable row came from. Absent for new/source rows. */
originalKey?: string;
/** The value is intentionally unavailable and must not be overwritten. */
preserveValue?: boolean;
/** Explicit secret classification; absent rows use the shared name heuristic. */
isSecret?: boolean;
}
export interface PersistedProjectEnv {
key: string;
/** Plaintext for readable rows; ENV_MASK for secrets. */
value: string;
isSecret: boolean;
}
export interface ProjectEnvDiff {
upserts: Array<{ key: string; value: string; isSecret: boolean }>;
deletes: string[];
}
export type ProjectEnvDiffResult =
| { ok: true; diff: ProjectEnvDiff }
| { ok: false; error: string };
export interface ProjectEnvEditState {
rows: EditableProjectEnvRow[];
baseline: PersistedProjectEnv[];
}
type EnvApiRow = {
key: string;
value: string;
isSecret: boolean;
environment: string;
};
type NormalizedProjectEnvRow = EditableProjectEnvRow & { key: string };
type NormalizedRowsResult =
| { ok: true; rows: NormalizedProjectEnvRow[] }
| { ok: false; error: string };
/**
* Build the editable rows and their immutable comparison snapshot together so
* no caller can accidentally load one without the other. Secret plaintext
* never enters browser state: the row is blank + preserved and the baseline
* carries only the shared mask sentinel.
*/
export function createProjectEnvEditState(
apiRows: readonly EnvApiRow[],
environment = "production",
): ProjectEnvEditState {
const baseline = apiRows
.filter((row) => row.environment === environment)
.map((row) => ({
key: row.key,
value: row.isSecret ? ENV_MASK : row.value,
isSecret: row.isSecret,
}));
return {
baseline,
rows: baseline.map((row) => ({
key: row.key,
originalKey: row.key,
value: row.isSecret ? "" : row.value,
visible: true,
preserveValue: row.isSecret || undefined,
isSecret: row.isSecret,
})),
};
}
/** Validate and trim the editable key column once for every persistence path. */
function normalizeProjectEnvRows(rows: readonly EditableProjectEnvRow[]): NormalizedRowsResult {
const normalized: NormalizedProjectEnvRow[] = [];
const seenKeys = new Set<string>();
for (const row of rows) {
const key = row.key.trim();
const completelyBlank =
!key && !row.value && row.originalKey === undefined && !row.preserveValue;
if (completelyBlank) continue;
if (!key) return { ok: false, error: "Every environment variable needs a name" };
if (seenKeys.has(key)) {
return { ok: false, error: `Duplicate environment variable "${key}"` };
}
seenKeys.add(key);
normalized.push({ ...row, key });
}
return { ok: true, rows: normalized };
}
/**
* Serialize env for a brand-new project. There is no persisted store to diff
* yet, so values travel through build/access. A preserved source row is sent as
* the mask sentinel; the API resolves it from the trusted source checkout.
*/
export function serializeNewProjectEnv(
rows: readonly EditableProjectEnvRow[],
): { ok: true; envVars: Record<string, string> | undefined } | { ok: false; error: string } {
const normalized = normalizeProjectEnvRows(rows);
if (!normalized.ok) return normalized;
const envVars = Object.fromEntries(
normalized.rows.map((row) => [
row.key,
row.preserveValue || isMaskedValue(row.value) ? ENV_MASK : row.value,
]),
);
return {
ok: true,
envVars: Object.keys(envVars).length > 0 ? envVars : undefined,
};
}
/**
* Compute a partial project-env merge from editable rows and the snapshot they
* were loaded with. Untouched masked values never leave the browser, while an
* explicit edit to an empty string remains a real upsert.
*
* Source-owned rows discovered from openship.json have no `originalKey` and
* may also be preserved. The deployment wizard can ignore those rows because
* build/access resolves them from source; ordinary editors reject them instead
* of pretending they were persisted.
*/
export function computeProjectEnvDiff(
rows: readonly EditableProjectEnvRow[],
baseline: readonly PersistedProjectEnv[],
options: { ignoreUntrackedPreserved?: boolean } = {},
): ProjectEnvDiffResult {
const normalized = normalizeProjectEnvRows(rows);
if (!normalized.ok) return normalized;
const baselineByKey = new Map<string, PersistedProjectEnv>();
for (const saved of baseline) {
if (baselineByKey.has(saved.key)) {
return { ok: false, error: "Reload the page before changing environment variables" };
}
baselineByKey.set(saved.key, saved);
}
const claimedOriginalKeys = new Set<string>();
const keptOriginalKeys = new Set<string>();
const upserts: ProjectEnvDiff["upserts"] = [];
for (const row of normalized.rows) {
const originalKey = row.originalKey;
const original = originalKey ? baselineByKey.get(originalKey) : undefined;
if (originalKey && !original) {
return {
ok: false,
error: `Reload the page before changing "${originalKey}"`,
};
}
if (originalKey && claimedOriginalKeys.has(originalKey)) {
return {
ok: false,
error: `Reload the page before changing "${originalKey}"`,
};
}
if (originalKey) claimedOriginalKeys.add(originalKey);
const preserved = Boolean(row.preserveValue) || isMaskedValue(row.value);
if (!original) {
if (preserved) {
if (options.ignoreUntrackedPreserved) continue;
return { ok: false, error: `Enter a value for "${row.key}"` };
}
upserts.push({
key: row.key,
value: row.value,
isSecret: row.isSecret ?? looksLikeSecretKey(row.key),
});
continue;
}
const isSecret = row.isSecret ?? original.isSecret;
const renamed = row.key !== original.key;
if (!renamed) keptOriginalKeys.add(original.key);
if (preserved) {
if (renamed || isSecret !== original.isSecret) {
return {
ok: false,
error: `Re-enter the value for "${original.key}" before changing it`,
};
}
continue;
}
if (renamed || row.value !== original.value || isSecret !== original.isSecret) {
upserts.push({ key: row.key, value: row.value, isSecret });
}
}
const upsertKeys = new Set(upserts.map((row) => row.key));
const deletes = baseline
.map((row) => row.key)
.filter((key) => !keptOriginalKeys.has(key) && !upsertKeys.has(key));
return { ok: true, diff: { upserts, deletes } };
}