mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix(dashboard): persist deployment env edits safely
This commit is contained in:
@@ -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: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user