FN-8808: bind dashboard secrets to selected project
Bind dashboard secret operations to the explicitly selected project. - Include project identity in secrets and sync-passphrase requests. - Reject missing project IDs before fallback project-store resolution. - Prevent stale cross-project UI responses and document recovery guidance. Files changed: .changeset/fn-8808-secrets-project-binding.md | 7 + docs/secrets.md | 6 + packages/dashboard/app/components/SecretsView.tsx | 134 ++++++++++++++----- .../dashboard/app/components/SettingsModal.tsx | 2 +- .../__tests__/SecretsView.mobile.test.tsx | 9 ++ .../app/components/__tests__/SecretsView.test.tsx | 137 ++++++++++++++++++++ .../__tests__/SettingsModal.mcp.test.tsx | 69 +++++++++- .../__tests__/SettingsModal.test-harness.tsx | 8 +- .../app/components/dashboard/MainContent.tsx | 2 +- .../app/components/overflowViewRegistry.tsx | 2 +- .../settings/sections/McpServersCard.tsx | 58 +++++++-- .../settings/sections/SecretsSection.tsx | 5 +- .../__tests__/register-secrets-routes.test.ts | 142 +++++++++++++++++++-- .../src/routes/register-secrets-routes.ts | 53 +++++--- 14 files changed, 551 insertions(+), 83 deletions(-) Fusion-Task-Id: FN-8808 Fusion-Task-Lineage: dd224dbe-46a5-49a6-b6e9-5d5bbe4e742e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8808-secrets-project-binding.md
Normal file
7
.changeset/fn-8808-secrets-project-binding.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Bind dashboard secret management to the selected project.
|
||||||
|
category: fix
|
||||||
|
dev: Secrets routes now reject requests without an explicit projectId before fallback context resolution.
|
||||||
@@ -113,6 +113,12 @@ Approval integration is active through `fn_secret_get` policy handling (`package
|
|||||||
|
|
||||||
Dashboard secrets CRUD is shipped via `SecretsView` (`packages/dashboard/app/components/SecretsView.tsx`), backed by the existing secrets API/store surfaces.
|
Dashboard secrets CRUD is shipped via `SecretsView` (`packages/dashboard/app/components/SecretsView.tsx`), backed by the existing secrets API/store surfaces.
|
||||||
|
|
||||||
|
Dashboard requests carry the currently selected `projectId`. Secrets routes require that explicit request identity **before** resolving project context, so a missing, empty, or whitespace-only id is rejected with HTTP 400 instead of selecting the daemon launch directory's fallback store. The selected id intentionally binds the project store: project-scoped rows use that project's RLS-protected `project.secrets` partition, while global-scoped rows still use shared `central.secrets_global` and remain visible from every selected project.
|
||||||
|
|
||||||
|
### Recovering pre-fix fallback rows
|
||||||
|
|
||||||
|
Older dashboard writes made without an explicit project id may be stranded in a `local-*` project partition associated with the daemon launch directory. Fusion does not move these rows automatically: their intended registered-project destination cannot be inferred safely. An operator who has independently identified the destination may reassign only the affected `project.secrets` rows using an audited database recovery procedure. Do not apply this procedure to `central.secrets_global`, and do not treat direct SQL reassignment as runtime architecture.
|
||||||
|
|
||||||
## Agent Access (`fn_secret_get`)
|
## Agent Access (`fn_secret_get`)
|
||||||
|
|
||||||
`fn_secret_get` is shipped in `packages/cli/src/extension.ts:1542-1629`.
|
`fn_secret_get` is shipped in `packages/cli/src/extension.ts:1542-1629`.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Check, ChevronDown, ChevronRight, Copy, Eye, EyeOff, Lock, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Check, ChevronDown, ChevronRight, Copy, Eye, EyeOff, Lock, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { ViewHeader } from "./ViewHeader";
|
import { ViewHeader } from "./ViewHeader";
|
||||||
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
|
import { withProjectId } from "../api/client/health";
|
||||||
|
|
||||||
type ToastKind = "info" | "success" | "error";
|
type ToastKind = "info" | "success" | "error";
|
||||||
type SecretScope = "project" | "global";
|
type SecretScope = "project" | "global";
|
||||||
@@ -22,6 +23,7 @@ interface SecretRecord {
|
|||||||
|
|
||||||
interface SecretsViewProps {
|
interface SecretsViewProps {
|
||||||
addToast?: (msg: string, kind?: ToastKind) => void;
|
addToast?: (msg: string, kind?: ToastKind) => void;
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESERVED_SYNC_PASSPHRASE_KEY = "__sync_passphrase__";
|
const RESERVED_SYNC_PASSPHRASE_KEY = "__sync_passphrase__";
|
||||||
@@ -57,11 +59,13 @@ const spinningActionIconProps = {
|
|||||||
className: "secrets-action-icon spin",
|
className: "secrets-action-icon spin",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
export const SecretsView = ({ addToast, projectId }: SecretsViewProps) => {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [secrets, setSecrets] = useState<SecretRecord[]>([]);
|
const [secrets, setSecrets] = useState<SecretRecord[]>([]);
|
||||||
|
const [secretsProjectId, setSecretsProjectId] = useState<string | undefined>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [errorProjectId, setErrorProjectId] = useState<string | undefined>();
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [editing, setEditing] = useState<SecretRecord | null>(null);
|
const [editing, setEditing] = useState<SecretRecord | null>(null);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -71,6 +75,7 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
const [revealedValues, setRevealedValues] = useState<Record<string, string | null>>({});
|
const [revealedValues, setRevealedValues] = useState<Record<string, string | null>>({});
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
const [syncPassphraseConfigured, setSyncPassphraseConfigured] = useState(false);
|
const [syncPassphraseConfigured, setSyncPassphraseConfigured] = useState(false);
|
||||||
|
const [syncStatusProjectId, setSyncStatusProjectId] = useState<string | undefined>();
|
||||||
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
||||||
const [syncPassphrase, setSyncPassphrase] = useState("");
|
const [syncPassphrase, setSyncPassphrase] = useState("");
|
||||||
const [syncPassphraseConfirm, setSyncPassphraseConfirm] = useState("");
|
const [syncPassphraseConfirm, setSyncPassphraseConfirm] = useState("");
|
||||||
@@ -84,6 +89,20 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
const [syncDisclosureOpen, setSyncDisclosureOpen] = useState(false);
|
const [syncDisclosureOpen, setSyncDisclosureOpen] = useState(false);
|
||||||
const revealTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const revealTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
const copyTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const copyTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
const projectRequestVersionRef = useRef(0);
|
||||||
|
const syncRequestVersionRef = useRef(0);
|
||||||
|
const activeProjectIdRef = useRef(projectId);
|
||||||
|
if (activeProjectIdRef.current !== projectId) {
|
||||||
|
activeProjectIdRef.current = projectId;
|
||||||
|
projectRequestVersionRef.current += 1;
|
||||||
|
syncRequestVersionRef.current += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Secrets 2026-08-05-21:37:
|
||||||
|
Dashboard secret requests must carry the selected project identity before the API resolves a store. Global scope remains shared in central.secrets_global; this query only prevents the daemon launch-store fallback from receiving project UI mutations.
|
||||||
|
*/
|
||||||
|
const projectUrl = useCallback((path: string) => withProjectId(path, projectId), [projectId]);
|
||||||
|
|
||||||
const request = useCallback(async <T,>(url: string, init?: RequestInit): Promise<T> => {
|
const request = useCallback(async <T,>(url: string, init?: RequestInit): Promise<T> => {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -102,35 +121,68 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadSecrets = useCallback(async () => {
|
const loadSecrets = useCallback(async () => {
|
||||||
|
const requestProjectId = projectId;
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
|
const requestVersion = ++projectRequestVersionRef.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setErrorProjectId(undefined);
|
||||||
try {
|
try {
|
||||||
const data = await request<{ secrets: SecretRecord[] }>("/api/secrets");
|
const data = await request<{ secrets: SecretRecord[] }>(projectUrl("/api/secrets"));
|
||||||
setSecrets(data.secrets);
|
if (requestVersion === projectRequestVersionRef.current && activeProjectIdRef.current === requestProjectId) {
|
||||||
|
setSecrets(data.secrets);
|
||||||
|
setSecretsProjectId(requestProjectId);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
if (requestVersion === projectRequestVersionRef.current && activeProjectIdRef.current === requestProjectId) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setErrorProjectId(requestProjectId);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (requestVersion === projectRequestVersionRef.current && activeProjectIdRef.current === requestProjectId) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [projectId, projectUrl, request]);
|
||||||
|
|
||||||
const loadSyncPassphraseStatus = useCallback(async () => {
|
const loadSyncPassphraseStatus = useCallback(async () => {
|
||||||
|
const requestProjectId = projectId;
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
|
const requestVersion = ++syncRequestVersionRef.current;
|
||||||
try {
|
try {
|
||||||
const data = await request<{ configured: boolean }>("/api/secrets/sync-passphrase");
|
const data = await request<{ configured: boolean }>(projectUrl("/api/secrets/sync-passphrase"));
|
||||||
setSyncPassphraseConfigured(Boolean(data.configured));
|
if (requestVersion === syncRequestVersionRef.current && activeProjectIdRef.current === requestProjectId) {
|
||||||
|
setSyncPassphraseConfigured(Boolean(data.configured));
|
||||||
|
setSyncStatusProjectId(requestProjectId);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast?.(t("secrets.errorLoadSyncStatus", "Failed to load sync passphrase status: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
|
if (requestVersion === syncRequestVersionRef.current && activeProjectIdRef.current === requestProjectId) addToast?.(t("secrets.errorLoadSyncStatus", "Failed to load sync passphrase status: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
|
||||||
}
|
}
|
||||||
}, [addToast, request]);
|
}, [addToast, projectId, projectUrl, request, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setSecrets([]);
|
||||||
|
setSecretsProjectId(undefined);
|
||||||
|
setRevealedValues({});
|
||||||
|
setCopiedId(null);
|
||||||
|
setSyncPassphraseConfigured(false);
|
||||||
|
setSyncStatusProjectId(undefined);
|
||||||
|
setError(null);
|
||||||
|
setErrorProjectId(undefined);
|
||||||
|
setFormError(null);
|
||||||
|
setEditing(null);
|
||||||
|
setShowModal(false);
|
||||||
|
setShowValue(false);
|
||||||
|
setShowDeleteId(null);
|
||||||
|
setSyncModalOpen(false);
|
||||||
|
setSyncPassphrase("");
|
||||||
|
setSyncPassphraseConfirm("");
|
||||||
|
setSyncSaving(false);
|
||||||
void loadSecrets();
|
void loadSecrets();
|
||||||
void loadSyncPassphraseStatus();
|
void loadSyncPassphraseStatus();
|
||||||
return () => {
|
return () => {
|
||||||
revealTimersRef.current.forEach((timer) => clearTimeout(timer));
|
revealTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||||
copyTimersRef.current.forEach((timer) => clearTimeout(timer));
|
copyTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||||
};
|
};
|
||||||
}, [loadSecrets, loadSyncPassphraseStatus]);
|
}, [loadSecrets, loadSyncPassphraseStatus, projectId]);
|
||||||
|
|
||||||
const closeSyncModal = () => {
|
const closeSyncModal = () => {
|
||||||
setSyncModalOpen(false);
|
setSyncModalOpen(false);
|
||||||
@@ -138,36 +190,44 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
setSyncPassphraseConfirm("");
|
setSyncPassphraseConfirm("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSyncPassphrase = async (passphrase: string) => {
|
const saveSyncPassphrase = async (passphrase: string, requestProjectId: string | undefined) => {
|
||||||
await request<{ success: boolean }>("/api/secrets/sync-passphrase", {
|
await request<{ success: boolean }>(withProjectId("/api/secrets/sync-passphrase", requestProjectId), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ passphrase }),
|
body: JSON.stringify({ passphrase }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Secrets 2026-08-05-22:19:
|
||||||
|
A mutation started for project A can resolve after the dashboard selects B. Its completion may persist at A, but must not close B's dialog, clear B's draft, show an A toast, or trigger a B refresh.
|
||||||
|
*/
|
||||||
const submitSyncPassphrase = async () => {
|
const submitSyncPassphrase = async () => {
|
||||||
|
const requestProjectId = projectId;
|
||||||
setSyncSaving(true);
|
setSyncSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveSyncPassphrase(syncPassphrase);
|
await saveSyncPassphrase(syncPassphrase, requestProjectId);
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
addToast?.(syncPassphraseConfigured ? t("secrets.syncPassphraseRotated", "Sync passphrase rotated") : t("secrets.syncPassphraseSet", "Sync passphrase set"), "success");
|
addToast?.(syncPassphraseConfigured ? t("secrets.syncPassphraseRotated", "Sync passphrase rotated") : t("secrets.syncPassphraseSet", "Sync passphrase set"), "success");
|
||||||
closeSyncModal();
|
closeSyncModal();
|
||||||
await loadSyncPassphraseStatus();
|
await loadSyncPassphraseStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast?.(t("secrets.errorSaveSyncPassphrase", "Failed to save sync passphrase: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
|
if (activeProjectIdRef.current === requestProjectId) addToast?.(t("secrets.errorSaveSyncPassphrase", "Failed to save sync passphrase: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSyncSaving(false);
|
if (activeProjectIdRef.current === requestProjectId) setSyncSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSyncPassphraseHandler = async () => {
|
const clearSyncPassphraseHandler = async () => {
|
||||||
const confirmed = window.confirm(t("secrets.confirmClearSyncPassphrase", "Clear the cross-node sync passphrase? Existing sync pairs will stop working until you set a new passphrase."));
|
const confirmed = window.confirm(t("secrets.confirmClearSyncPassphrase", "Clear the cross-node sync passphrase? Existing sync pairs will stop working until you set a new passphrase."));
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
const requestProjectId = projectId;
|
||||||
try {
|
try {
|
||||||
await request<{ success: boolean }>("/api/secrets/sync-passphrase", { method: "DELETE" });
|
await request<{ success: boolean }>(withProjectId("/api/secrets/sync-passphrase", requestProjectId), { method: "DELETE" });
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
addToast?.(t("secrets.syncPassphraseCleared", "Sync passphrase cleared"), "success");
|
addToast?.(t("secrets.syncPassphraseCleared", "Sync passphrase cleared"), "success");
|
||||||
await loadSyncPassphraseStatus();
|
await loadSyncPassphraseStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast?.(t("secrets.errorClearSyncPassphrase", "Failed to clear sync passphrase: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
|
if (activeProjectIdRef.current === requestProjectId) addToast?.(t("secrets.errorClearSyncPassphrase", "Failed to clear sync passphrase: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,6 +256,7 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
const requestProjectId = projectId;
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -207,12 +268,12 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
envExportKey: form.envExportable ? (form.envExportKey || null) : null,
|
envExportKey: form.envExportable ? (form.envExportKey || null) : null,
|
||||||
};
|
};
|
||||||
if (form.value) body.value = form.value;
|
if (form.value) body.value = form.value;
|
||||||
await request(`/api/secrets/${editing.scope}/${editing.id}`, {
|
await request(projectUrl(`/api/secrets/${editing.scope}/${editing.id}`), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await request("/api/secrets", {
|
await request(projectUrl("/api/secrets"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
scope: form.scope,
|
scope: form.scope,
|
||||||
@@ -225,11 +286,12 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
await loadSecrets();
|
await loadSecrets();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFormError(err instanceof Error ? err.message : String(err));
|
if (activeProjectIdRef.current === requestProjectId) setFormError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,7 +305,9 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const revealSecret = async (secret: SecretRecord) => {
|
const revealSecret = async (secret: SecretRecord) => {
|
||||||
const data = await request<{ key: string; value: string }>(`/api/secrets/${secret.scope}/${secret.id}/reveal`, { method: "POST" });
|
const requestProjectId = projectId;
|
||||||
|
const data = await request<{ key: string; value: string }>(projectUrl(`/api/secrets/${secret.scope}/${secret.id}/reveal`), { method: "POST" });
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
setRevealedValues((current) => ({ ...current, [secret.id]: data.value }));
|
setRevealedValues((current) => ({ ...current, [secret.id]: data.value }));
|
||||||
addToast?.(t("secrets.revealed", "Revealed"), "success");
|
addToast?.(t("secrets.revealed", "Revealed"), "success");
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -279,16 +343,24 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteSecret = async (secret: SecretRecord) => {
|
const deleteSecret = async (secret: SecretRecord) => {
|
||||||
await request(`/api/secrets/${secret.scope}/${secret.id}`, { method: "DELETE" });
|
const requestProjectId = projectId;
|
||||||
|
await request(withProjectId(`/api/secrets/${secret.scope}/${secret.id}`, requestProjectId), { method: "DELETE" });
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
setShowDeleteId(null);
|
setShowDeleteId(null);
|
||||||
await loadSecrets();
|
await loadSecrets();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Secrets 2026-08-05-21:57:
|
||||||
|
Project switches render before effects can clear state. Associate asynchronous secret and sync responses with their request project so A-only rows, revealed values, and configured status never paint while B is selected.
|
||||||
|
*/
|
||||||
|
const secretsAreCurrent = secretsProjectId === projectId;
|
||||||
|
const syncStatusIsCurrent = syncStatusProjectId === projectId;
|
||||||
const sortedSecrets = useMemo(
|
const sortedSecrets = useMemo(
|
||||||
() => [...secrets]
|
() => [...(secretsAreCurrent ? secrets : [])]
|
||||||
.filter((secret) => !(secret.scope === "global" && secret.key === RESERVED_SYNC_PASSPHRASE_KEY))
|
.filter((secret) => !(secret.scope === "global" && secret.key === RESERVED_SYNC_PASSPHRASE_KEY))
|
||||||
.sort((a, b) => a.key.localeCompare(b.key)),
|
.sort((a, b) => a.key.localeCompare(b.key)),
|
||||||
[secrets],
|
[secrets, secretsAreCurrent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncPassphraseMatches = syncPassphrase.length > 0 && syncPassphrase === syncPassphraseConfirm;
|
const syncPassphraseMatches = syncPassphrase.length > 0 && syncPassphrase === syncPassphraseConfirm;
|
||||||
@@ -310,9 +382,9 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? <div className="form-error">{error}</div> : null}
|
{error && errorProjectId === projectId ? <div className="form-error">{error}</div> : null}
|
||||||
{loading ? <div className="secrets-loading"><RefreshCw {...spinningActionIconProps} /> {t("secrets.loading", "Loading…")}</div> : null}
|
{loading || !secretsAreCurrent ? <div className="secrets-loading"><RefreshCw {...spinningActionIconProps} /> {t("secrets.loading", "Loading…")}</div> : null}
|
||||||
{!loading && sortedSecrets.length === 0 ? <div className="secrets-empty">{t("secrets.empty", "No secrets found.")}</div> : null}
|
{!loading && secretsAreCurrent && sortedSecrets.length === 0 ? <div className="secrets-empty">{t("secrets.empty", "No secrets found.")}</div> : null}
|
||||||
|
|
||||||
<div className="secrets-list">
|
<div className="secrets-list">
|
||||||
{sortedSecrets.map((secret) => {
|
{sortedSecrets.map((secret) => {
|
||||||
@@ -383,10 +455,10 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
{syncDisclosureOpen ? (
|
{syncDisclosureOpen ? (
|
||||||
<div id="secrets-sync-disclosure-panel" className="secrets-sync-disclosure-panel">
|
<div id="secrets-sync-disclosure-panel" className="secrets-sync-disclosure-panel">
|
||||||
<div className="secrets-sync-header">
|
<div className="secrets-sync-header">
|
||||||
<p className="secrets-sync-status"><span className={`status-dot ${syncPassphraseConfigured ? "status-dot--online" : "status-dot--pending"}`} aria-hidden="true" /> {syncPassphraseConfigured ? t("secrets.syncConfigured", "Configured") : t("secrets.syncNotConfigured", "Not configured")}</p>
|
<p className="secrets-sync-status"><span className={`status-dot ${syncStatusIsCurrent && syncPassphraseConfigured ? "status-dot--online" : "status-dot--pending"}`} aria-hidden="true" /> {syncStatusIsCurrent && syncPassphraseConfigured ? t("secrets.syncConfigured", "Configured") : t("secrets.syncNotConfigured", "Not configured")}</p>
|
||||||
<div className="secrets-sync-actions">
|
<div className="secrets-sync-actions">
|
||||||
<button className="btn" onClick={() => setSyncModalOpen(true)}>{syncPassphraseConfigured ? t("secrets.rotateSyncPassphrase", "Rotate") : t("secrets.setPassphrase", "Set passphrase")}</button>
|
<button className="btn" onClick={() => setSyncModalOpen(true)}>{syncStatusIsCurrent && syncPassphraseConfigured ? t("secrets.rotateSyncPassphrase", "Rotate") : t("secrets.setPassphrase", "Set passphrase")}</button>
|
||||||
{syncPassphraseConfigured ? <button className="btn btn-danger" onClick={() => void clearSyncPassphraseHandler()}>{t("secrets.clearSyncPassphrase", "Clear")}</button> : null}
|
{syncStatusIsCurrent && syncPassphraseConfigured ? <button className="btn btn-danger" onClick={() => void clearSyncPassphraseHandler()}>{t("secrets.clearSyncPassphrase", "Clear")}</button> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="secrets-sync-copy">
|
<p className="secrets-sync-copy">
|
||||||
|
|||||||
@@ -4069,7 +4069,7 @@ export function SettingsModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "secrets":
|
case "secrets":
|
||||||
return <SecretsSection addToast={addToast} />;
|
return <SecretsSection addToast={addToast} projectId={projectId} />;
|
||||||
case "global-mcp":
|
case "global-mcp":
|
||||||
return (
|
return (
|
||||||
<GlobalMcpSection
|
<GlobalMcpSection
|
||||||
|
|||||||
@@ -151,6 +151,15 @@ describe("SecretsView mobile layout contracts", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("uses the selected project for mobile initial requests", async () => {
|
||||||
|
const fetchMock = seedFetchForSecretsList();
|
||||||
|
render(<SecretsView addToast={vi.fn()} projectId="proj-mobile" />);
|
||||||
|
|
||||||
|
await screen.findByText("LONG_SECRET_KEY_NAME_FOR_WRAP_TESTING");
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj-mobile", expect.anything());
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/secrets/sync-passphrase?projectId=proj-mobile", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
it("renders add-secret modal body with scoped spacing", async () => {
|
it("renders add-secret modal body with scoped spacing", async () => {
|
||||||
const removeCss = installCss(baseCss);
|
const removeCss = installCss(baseCss);
|
||||||
seedFetchForSecretsList();
|
seedFetchForSecretsList();
|
||||||
|
|||||||
@@ -108,6 +108,143 @@ describe("SecretsView", () => {
|
|||||||
delete document.documentElement.dataset.theme;
|
delete document.documentElement.dataset.theme;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("binds list and sync status requests to the selected project", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { secrets: [] } }))
|
||||||
|
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
render(<SecretsView addToast={vi.fn()} projectId="proj A" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj%20A", expect.anything());
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/secrets/sync-passphrase?projectId=proj%20A", expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops stale project rows when project selection changes before a prior response resolves", async () => {
|
||||||
|
let resolveA!: (response: Response) => void;
|
||||||
|
let resolveB!: (response: Response) => void;
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/secrets?projectId=proj_A") return new Promise<Response>((resolve) => { resolveA = resolve; });
|
||||||
|
if (url === "/api/secrets?projectId=proj_B") return new Promise<Response>((resolve) => { resolveB = resolve; });
|
||||||
|
if (url.includes("/sync-passphrase")) return Promise.resolve(mockJsonResponse({ ok: true, body: { configured: false } }));
|
||||||
|
return Promise.resolve(mockJsonResponse({ ok: true, body: { secrets: [] } }));
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { rerender } = render(<SecretsView addToast={vi.fn()} projectId="proj_A" />);
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj_A", expect.anything()));
|
||||||
|
|
||||||
|
rerender(<SecretsView addToast={vi.fn()} projectId="proj_B" />);
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj_B", expect.anything()));
|
||||||
|
expect(screen.queryByText("A_ONLY")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
resolveB(mockJsonResponse({ ok: true, body: { secrets: [
|
||||||
|
{ id: "b", key: "B_ONLY", scope: "project", description: null, accessPolicy: "prompt", envExportable: false, envExportKey: null, lastReadAt: null },
|
||||||
|
{ id: "global", key: "SHARED", scope: "global", description: null, accessPolicy: "prompt", envExportable: false, envExportKey: null, lastReadAt: null },
|
||||||
|
] } }));
|
||||||
|
expect(await screen.findByText("B_ONLY")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("SHARED")).toBeInTheDocument();
|
||||||
|
|
||||||
|
resolveA(mockJsonResponse({ ok: true, body: { secrets: [{ id: "a", key: "A_ONLY", scope: "project", description: null, accessPolicy: "prompt", envExportable: false, envExportKey: null, lastReadAt: null }] } }));
|
||||||
|
await waitFor(() => expect(screen.getByText("B_ONLY")).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText("A_ONLY")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an A mutation completion close or clear B's secret draft", async () => {
|
||||||
|
let resolveCreateA!: (response: Response) => void;
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
if (url === "/api/secrets?projectId=proj_A" && method === "POST") return new Promise<Response>((resolve) => { resolveCreateA = resolve; });
|
||||||
|
if (url.startsWith("/api/secrets?") && method === "GET") return Promise.resolve(mockJsonResponse({ ok: true, body: { secrets: [] } }));
|
||||||
|
if (url.includes("/sync-passphrase") && method === "GET") return Promise.resolve(mockJsonResponse({ ok: true, body: { configured: false } }));
|
||||||
|
return Promise.resolve(mockJsonResponse({ ok: true, body: { success: true } }));
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { rerender } = render(<SecretsView addToast={vi.fn()} projectId="proj_A" />);
|
||||||
|
await screen.findByText("No secrets found.");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Add Secret" }));
|
||||||
|
let dialog = screen.getByRole("dialog", { name: "Add secret" });
|
||||||
|
let inputs = within(dialog).getAllByRole("textbox");
|
||||||
|
await userEvent.type(inputs[0]!, "A_PENDING");
|
||||||
|
await userEvent.type(inputs[1]!, "a-value");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj_A", expect.objectContaining({ method: "POST" })));
|
||||||
|
|
||||||
|
rerender(<SecretsView addToast={vi.fn()} projectId="proj_B" />);
|
||||||
|
await screen.findByText("No secrets found.");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Add Secret" }));
|
||||||
|
dialog = screen.getByRole("dialog", { name: "Add secret" });
|
||||||
|
inputs = within(dialog).getAllByRole("textbox");
|
||||||
|
await userEvent.type(inputs[0]!, "B_DRAFT");
|
||||||
|
|
||||||
|
resolveCreateA(mockJsonResponse({ ok: true, status: 201, body: { id: "a", key: "A_PENDING" } }));
|
||||||
|
await waitFor(() => expect(within(screen.getByRole("dialog", { name: "Add secret" })).getAllByRole("textbox")[0]).toHaveValue("B_DRAFT"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds create, update, reveal, delete, and sync mutations to the selected project", async () => {
|
||||||
|
const projectId = "proj_actions";
|
||||||
|
const baseUrl = `/api/secrets?projectId=${projectId}`;
|
||||||
|
const row = { id: "secret-1", key: "VISIBLE", scope: "project" as const, description: null, accessPolicy: "prompt" as const, envExportable: false, envExportKey: null, lastReadAt: null };
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
if (url === baseUrl && method === "GET") return Promise.resolve(mockJsonResponse({ ok: true, body: { secrets: [row] } }));
|
||||||
|
if (url === baseUrl && method === "POST") return Promise.resolve(mockJsonResponse({ ok: true, status: 201, body: { ...row, id: "created", key: "CREATED" } }));
|
||||||
|
if (url === "/api/secrets/sync-passphrase?projectId=proj_actions" && method === "GET") return Promise.resolve(mockJsonResponse({ ok: true, body: { configured: true } }));
|
||||||
|
if (url === "/api/secrets/sync-passphrase?projectId=proj_actions" && method === "PUT") return Promise.resolve(mockJsonResponse({ ok: true, body: { success: true } }));
|
||||||
|
if (url === "/api/secrets/sync-passphrase?projectId=proj_actions" && method === "DELETE") return Promise.resolve(mockJsonResponse({ ok: true, body: { success: true } }));
|
||||||
|
if (url === "/api/secrets/project/secret-1?projectId=proj_actions" && method === "PATCH") return Promise.resolve(mockJsonResponse({ ok: true, body: row }));
|
||||||
|
if (url === "/api/secrets/project/secret-1/reveal?projectId=proj_actions" && method === "POST") return Promise.resolve(mockJsonResponse({ ok: true, body: { key: row.key, value: "revealed-value" } }));
|
||||||
|
if (url === "/api/secrets/project/secret-1?projectId=proj_actions" && method === "DELETE") return Promise.resolve(mockJsonResponse({ ok: true, status: 204, body: undefined }));
|
||||||
|
return Promise.resolve(mockJsonResponse({ ok: false, body: { error: `Unhandled ${method} ${url}` } }));
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<SecretsView addToast={vi.fn()} projectId={projectId} />);
|
||||||
|
await screen.findByText("VISIBLE");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Add Secret" }));
|
||||||
|
let dialog = screen.getByRole("dialog", { name: "Add secret" });
|
||||||
|
const createInputs = within(dialog).getAllByRole("textbox");
|
||||||
|
await userEvent.type(createInputs[0]!, "CREATED");
|
||||||
|
await userEvent.type(createInputs[1]!, "created-value");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(baseUrl, expect.objectContaining({ method: "POST" })));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
dialog = screen.getByRole("dialog", { name: "Edit secret" });
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets/project/secret-1?projectId=proj_actions", expect.objectContaining({ method: "PATCH" })));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Reveal" }));
|
||||||
|
expect(await screen.findByText("revealed-value")).toBeInTheDocument();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("/api/secrets/project/secret-1/reveal?projectId=proj_actions", expect.objectContaining({ method: "POST" }));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets/project/secret-1?projectId=proj_actions", expect.objectContaining({ method: "DELETE" })));
|
||||||
|
|
||||||
|
await expandPassphraseDisclosure();
|
||||||
|
await screen.findByText("Configured");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Rotate" }));
|
||||||
|
dialog = screen.getByRole("dialog", { name: "Rotate sync passphrase" });
|
||||||
|
const passphraseInputs = dialog.querySelectorAll("input");
|
||||||
|
await userEvent.type(passphraseInputs[0]!, "new-passphrase");
|
||||||
|
await userEvent.type(passphraseInputs[1]!, "new-passphrase");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Rotate" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets/sync-passphrase?projectId=proj_actions", expect.objectContaining({ method: "PUT" })));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets/sync-passphrase?projectId=proj_actions", expect.objectContaining({ method: "DELETE" })));
|
||||||
|
});
|
||||||
|
|
||||||
it("renders Not configured status", async () => {
|
it("renders Not configured status", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -48,12 +48,14 @@ function mockFetch(statusByName: Record<string, { status: "valid" | "unreachable
|
|||||||
const scope = (new URL(url, "https://fusion.test").searchParams.get("scope") === "global" ? "global" : "project") as McpSettingsScope;
|
const scope = (new URL(url, "https://fusion.test").searchParams.get("scope") === "global" ? "global" : "project") as McpSettingsScope;
|
||||||
return new Response(JSON.stringify(discoveryByScope?.[scope] ?? { sources: [], servers: [], errors: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
return new Response(JSON.stringify(discoveryByScope?.[scope] ?? { sources: [], servers: [], errors: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
}
|
}
|
||||||
if (url === "/api/secrets" && (!init?.method || init.method === "GET")) {
|
if (url.startsWith("/api/secrets?") || url === "/api/secrets") {
|
||||||
return new Response(JSON.stringify({ secrets: [secret] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
if (!init?.method || init.method === "GET") {
|
||||||
}
|
return new Response(JSON.stringify({ secrets: [secret] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
if (url === "/api/secrets" && init?.method === "POST") {
|
}
|
||||||
|
if (init?.method === "POST") {
|
||||||
const body = JSON.parse(String(init.body ?? "{}")) as { key?: string; scope?: "project" | "global" };
|
const body = JSON.parse(String(init.body ?? "{}")) as { key?: string; scope?: "project" | "global" };
|
||||||
return new Response(JSON.stringify({ ...secret, id: `created-${body.key ?? "secret"}`, key: body.key ?? "TOKEN", scope: body.scope ?? "project" }), { status: 201, headers: { "Content-Type": "application/json" } });
|
return new Response(JSON.stringify({ ...secret, id: `created-${body.key ?? "secret"}`, key: body.key ?? "TOKEN", scope: body.scope ?? "project" }), { status: 201, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (url === "/api/mcp/validate") {
|
if (url === "/api/mcp/validate") {
|
||||||
const body = JSON.parse(String(init?.body ?? "{}")) as { server?: { name?: string } };
|
const body = JSON.parse(String(init?.body ?? "{}")) as { server?: { name?: string } };
|
||||||
@@ -74,7 +76,7 @@ function expectButtonIconSize(button: HTMLElement, size: "14" | "16") {
|
|||||||
expect(icon).toHaveAttribute("height", size);
|
expect(icon).toHaveAttribute("height", size);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalSettings?: Pick<GlobalSettings, "mcpServers"> | null; pluginServers?: Array<{ pluginId: string; server: { name: string; transport: "stdio"; command: string; enabledByDefault?: boolean } }> }) {
|
function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalSettings?: Pick<GlobalSettings, "mcpServers"> | null; projectId?: string; pluginServers?: Array<{ pluginId: string; server: { name: string; transport: "stdio"; command: string; enabledByDefault?: boolean } }> }) {
|
||||||
let currentForm: Settings = options.form ?? ({} as Settings);
|
let currentForm: Settings = options.form ?? ({} as Settings);
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
function Harness() {
|
function Harness() {
|
||||||
@@ -85,6 +87,7 @@ function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalS
|
|||||||
scope={options.scope}
|
scope={options.scope}
|
||||||
form={form}
|
form={form}
|
||||||
globalSettings={options.globalSettings}
|
globalSettings={options.globalSettings}
|
||||||
|
projectId={options.projectId}
|
||||||
pluginServers={options.pluginServers}
|
pluginServers={options.pluginServers}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
setForm={(next) => {
|
setForm={(next) => {
|
||||||
@@ -144,6 +147,60 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("MCP Settings UI", () => {
|
describe("MCP Settings UI", () => {
|
||||||
|
it("binds MCP secret listing to its selected project", async () => {
|
||||||
|
const fetchMock = mockFetch();
|
||||||
|
renderCard({ scope: "project", projectId: "proj_A" });
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj_A", expect.anything()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops stale MCP secret options when the selected project changes", async () => {
|
||||||
|
let resolveA!: (response: Response) => void;
|
||||||
|
let resolveB!: (response: Response) => void;
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/secrets?projectId=proj_A") return new Promise<Response>((resolve) => { resolveA = resolve; });
|
||||||
|
if (url === "/api/secrets?projectId=proj_B" && init?.method === "POST") {
|
||||||
|
const body = JSON.parse(String(init.body)) as { key: string; scope: string };
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ ...secret, id: `created-${body.key}`, key: body.key, scope: body.scope }), { status: 201, headers: { "Content-Type": "application/json" } }));
|
||||||
|
}
|
||||||
|
if (url === "/api/secrets?projectId=proj_B") return new Promise<Response>((resolve) => { resolveB = resolve; });
|
||||||
|
if (url.startsWith("/api/mcp/discovered")) return Promise.resolve(new Response(JSON.stringify({ sources: [], servers: [], errors: [] }), { status: 200, headers: { "Content-Type": "application/json" } }));
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ error: `Unhandled ${url}` }), { status: 500, headers: { "Content-Type": "application/json" } }));
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const props = { scope: "project" as const, form: {} as Settings, setForm: vi.fn(), addToast: vi.fn() };
|
||||||
|
const { rerender } = render(<McpServersCard {...props} projectId="proj_A" />);
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj_A", expect.anything()));
|
||||||
|
|
||||||
|
rerender(<McpServersCard {...props} projectId="proj_B" />);
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj_B", expect.anything()));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Add server/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Add secret reference/i }));
|
||||||
|
const picker = screen.getByRole("combobox", { name: "Secret reference" });
|
||||||
|
expect(within(picker).queryByRole("option", { name: /A_ONLY/ })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
resolveB(new Response(JSON.stringify({ secrets: [
|
||||||
|
{ ...secret, id: "b", key: "B_ONLY" },
|
||||||
|
{ ...secret, id: "global", key: "SHARED", scope: "global" },
|
||||||
|
] }), { status: 200, headers: { "Content-Type": "application/json" } }));
|
||||||
|
await screen.findByRole("option", { name: /B_ONLY/ });
|
||||||
|
expect(within(picker).getByRole("option", { name: /SHARED/ })).toBeInTheDocument();
|
||||||
|
|
||||||
|
resolveA(new Response(JSON.stringify({ secrets: [{ ...secret, id: "a", key: "A_ONLY" }] }), { status: 200, headers: { "Content-Type": "application/json" } }));
|
||||||
|
await waitFor(() => expect(within(picker).getByRole("option", { name: /B_ONLY/ })).toBeInTheDocument());
|
||||||
|
expect(within(picker).queryByRole("option", { name: /A_ONLY/ })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Cancel$/i }));
|
||||||
|
const importPayload = JSON.stringify({ mcpServers: { imported: { command: "node", env: { TOKEN: "secret" } } } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Paste Claude Desktop mcpServers JSON"), { target: { value: importPayload } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Import$/i }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/secrets?projectId=proj_B",
|
||||||
|
expect.objectContaining({ method: "POST", body: expect.stringContaining('"scope":"project"') }),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
it("renders global and project MCP section affordances without a new lazy view", async () => {
|
it("renders global and project MCP section affordances without a new lazy view", async () => {
|
||||||
render(<GlobalMcpSection form={{} as Settings} setForm={vi.fn()} addToast={vi.fn()} />);
|
render(<GlobalMcpSection form={{} as Settings} setForm={vi.fn()} addToast={vi.fn()} />);
|
||||||
expect(await screen.findByTestId("mcp-servers-card-global")).toBeInTheDocument();
|
expect(await screen.findByTestId("mcp-servers-card-global")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -377,20 +377,20 @@ export function installSettingsModalEnv(options?: { advancedSettings?: boolean }
|
|||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.endsWith("/api/secrets")) {
|
if (url.startsWith("/api/secrets/sync-passphrase")) {
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({ secrets: [] }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (url.endsWith("/api/secrets/sync-passphrase")) {
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({ configured: false }),
|
json: async () => ({ configured: false }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (url.startsWith("/api/secrets")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ secrets: [] }),
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -684,7 +684,7 @@ export function MainContent({
|
|||||||
return (
|
return (
|
||||||
<PageErrorBoundary>
|
<PageErrorBoundary>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<SecretsView addToast={addToast} />
|
<SecretsView addToast={addToast} projectId={currentProject?.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</PageErrorBoundary>
|
</PageErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
|
|||||||
label: "Secrets",
|
label: "Secrets",
|
||||||
icon: Lock,
|
icon: Lock,
|
||||||
testId: "right-dock-tab-secrets",
|
testId: "right-dock-tab-secrets",
|
||||||
render: (props) => wrapOverflowView(<SecretsView addToast={props.addToast} />),
|
render: (props) => wrapOverflowView(<SecretsView addToast={props.addToast} projectId={props.projectId} />),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "pull-requests",
|
key: "pull-requests",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SettingsHelpTip } from "../SettingsHelpTip";
|
|||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { withProjectId } from "../../../api/client/health";
|
||||||
import {
|
import {
|
||||||
exportMcpServersJson,
|
exportMcpServersJson,
|
||||||
importMcpServersJson,
|
importMcpServersJson,
|
||||||
@@ -267,6 +268,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
|||||||
const [editor, setEditor] = useState<EditorDraft | null>(null);
|
const [editor, setEditor] = useState<EditorDraft | null>(null);
|
||||||
const [editorError, setEditorError] = useState<string | null>(null);
|
const [editorError, setEditorError] = useState<string | null>(null);
|
||||||
const [secrets, setSecrets] = useState<SecretRecord[]>([]);
|
const [secrets, setSecrets] = useState<SecretRecord[]>([]);
|
||||||
|
const [secretsProjectId, setSecretsProjectId] = useState<string | undefined>();
|
||||||
const [secretsError, setSecretsError] = useState<string | null>(null);
|
const [secretsError, setSecretsError] = useState<string | null>(null);
|
||||||
const [importText, setImportText] = useState("");
|
const [importText, setImportText] = useState("");
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
@@ -275,18 +277,39 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
|||||||
const [discovered, setDiscovered] = useState<DiscoveredMcpResponse | null>(null);
|
const [discovered, setDiscovered] = useState<DiscoveredMcpResponse | null>(null);
|
||||||
const [discoveryLoading, setDiscoveryLoading] = useState(false);
|
const [discoveryLoading, setDiscoveryLoading] = useState(false);
|
||||||
const [discoveryError, setDiscoveryError] = useState<string | null>(null);
|
const [discoveryError, setDiscoveryError] = useState<string | null>(null);
|
||||||
|
const secretsRequestVersionRef = useRef(0);
|
||||||
|
const activeProjectIdRef = useRef(projectId);
|
||||||
|
if (activeProjectIdRef.current !== projectId) {
|
||||||
|
activeProjectIdRef.current = projectId;
|
||||||
|
secretsRequestVersionRef.current += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Secrets 2026-08-05-21:37:
|
||||||
|
MCP secret references use the same selected-project request binding as SecretsView. The selected context chooses a safe store; a global secret body still dispatches to central.secrets_global.
|
||||||
|
*/
|
||||||
const reloadSecrets = useCallback(async () => {
|
const reloadSecrets = useCallback(async () => {
|
||||||
|
const requestProjectId = projectId;
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
|
const requestVersion = ++secretsRequestVersionRef.current;
|
||||||
try {
|
try {
|
||||||
const data = await requestJson<{ secrets: SecretRecord[] }>("/api/secrets");
|
const data = await requestJson<{ secrets: SecretRecord[] }>(withProjectId("/api/secrets", requestProjectId));
|
||||||
setSecrets(data.secrets);
|
if (secretsRequestVersionRef.current === requestVersion && activeProjectIdRef.current === requestProjectId) {
|
||||||
setSecretsError(null);
|
setSecrets(data.secrets);
|
||||||
|
setSecretsProjectId(requestProjectId);
|
||||||
|
setSecretsError(null);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSecretsError(error instanceof Error ? error.message : String(error));
|
if (secretsRequestVersionRef.current === requestVersion && activeProjectIdRef.current === requestProjectId) setSecretsError(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setSecrets([]);
|
||||||
|
setSecretsProjectId(undefined);
|
||||||
|
setSecretsError(null);
|
||||||
|
setEditor(null);
|
||||||
|
setEditorError(null);
|
||||||
void reloadSecrets();
|
void reloadSecrets();
|
||||||
}, [reloadSecrets]);
|
}, [reloadSecrets]);
|
||||||
|
|
||||||
@@ -426,13 +449,14 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
|||||||
|
|
||||||
const createSecretForRow = async (row: SensitiveRowDraft, field: "env" | "headers") => {
|
const createSecretForRow = async (row: SensitiveRowDraft, field: "env" | "headers") => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
const requestProjectId = projectId;
|
||||||
const key = row.createKey.trim() || row.key.trim();
|
const key = row.createKey.trim() || row.key.trim();
|
||||||
if (!key || !row.createValue) {
|
if (!key || !row.createValue) {
|
||||||
setEditorError(t("settings.mcp.secretCreateRequired", "Secret key and value are required."));
|
setEditorError(t("settings.mcp.secretCreateRequired", "Secret key and value are required."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const secret = await requestJson<SecretRecord>("/api/secrets", {
|
const secret = await requestJson<SecretRecord>(withProjectId("/api/secrets", requestProjectId), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
scope: row.scope,
|
scope: row.scope,
|
||||||
@@ -444,14 +468,15 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
|||||||
envExportKey: null,
|
envExportKey: null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
setEditor((current) => current && {
|
setEditor((current) => current && {
|
||||||
...current,
|
...current,
|
||||||
[field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, secretRef: secret.id, scope: secret.scope, createKey: secret.key, createValue: "" } : candidate),
|
[field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, secretRef: secret.id, scope: secret.scope, createKey: secret.key, createValue: "" } : candidate),
|
||||||
});
|
});
|
||||||
await reloadSecrets();
|
await reloadSecrets();
|
||||||
addToast(t("settings.mcp.secretCreated", "Secret created"), "success");
|
if (activeProjectIdRef.current === requestProjectId) addToast(t("settings.mcp.secretCreated", "Secret created"), "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setEditorError(error instanceof Error ? error.message : String(error));
|
if (activeProjectIdRef.current === requestProjectId) setEditorError(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -481,10 +506,12 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
|||||||
setImportError(t("settings.mcp.importDuplicate", "Duplicate MCP server name: {{name}}", { name: duplicate.name }));
|
setImportError(t("settings.mcp.importDuplicate", "Duplicate MCP server name: {{name}}", { name: duplicate.name }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestProjectId = projectId;
|
||||||
try {
|
try {
|
||||||
const refBySuggestedKey = new Map<string, McpSecretRef>();
|
const refBySuggestedKey = new Map<string, McpSecretRef>();
|
||||||
for (const descriptor of result.secretsToCreate) {
|
for (const descriptor of result.secretsToCreate) {
|
||||||
const secret = await requestJson<SecretRecord>("/api/secrets", {
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
|
const secret = await requestJson<SecretRecord>(withProjectId("/api/secrets", requestProjectId), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
scope: descriptor.scope,
|
scope: descriptor.scope,
|
||||||
@@ -498,6 +525,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
|||||||
});
|
});
|
||||||
refBySuggestedKey.set(descriptor.suggestedKey, { secretRef: secret.id, scope: secret.scope });
|
refBySuggestedKey.set(descriptor.suggestedKey, { secretRef: secret.id, scope: secret.scope });
|
||||||
}
|
}
|
||||||
|
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||||
const definitions = result.definitions.map((server) => {
|
const definitions = result.definitions.map((server) => {
|
||||||
if (server.transport === "stdio") {
|
if (server.transport === "stdio") {
|
||||||
const env = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, isMcpSecretRef(value) && refBySuggestedKey.has(value.secretRef) ? refBySuggestedKey.get(value.secretRef)! : value]));
|
const env = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, isMcpSecretRef(value) && refBySuggestedKey.has(value.secretRef) ? refBySuggestedKey.get(value.secretRef)! : value]));
|
||||||
@@ -509,9 +537,9 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
|||||||
updateMcpSettings({ ...settings, enabled: true, servers: [...configuredServers, ...definitions] });
|
updateMcpSettings({ ...settings, enabled: true, servers: [...configuredServers, ...definitions] });
|
||||||
setImportText("");
|
setImportText("");
|
||||||
await reloadSecrets();
|
await reloadSecrets();
|
||||||
addToast(t("settings.mcp.imported", "MCP servers imported"), "success");
|
if (activeProjectIdRef.current === requestProjectId) addToast(t("settings.mcp.imported", "MCP servers imported"), "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setImportError(error instanceof Error ? error.message : String(error));
|
if (activeProjectIdRef.current === requestProjectId) setImportError(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -526,6 +554,12 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:Secrets 2026-08-05-21:57:
|
||||||
|
An MCP card can re-render for a new project before its reload effect runs. Hide the prior project's secret options synchronously and reject late responses so an A-only reference cannot be selected or imported under B.
|
||||||
|
*/
|
||||||
|
const visibleSecrets = secretsProjectId === projectId ? secrets : [];
|
||||||
|
|
||||||
const renderSensitiveRows = (field: "env" | "headers", rows: SensitiveRowDraft[]) => (
|
const renderSensitiveRows = (field: "env" | "headers", rows: SensitiveRowDraft[]) => (
|
||||||
<div className="mcp-sensitive-list" data-testid={`mcp-${field}-rows`}>
|
<div className="mcp-sensitive-list" data-testid={`mcp-${field}-rows`}>
|
||||||
{/* FNXC:McpConfig 2026-06-26-01:17: This picker is the only UI seam for MCP env/header secrets. Operators may create a secret value here, but the settings draft receives only { secretRef, scope }, never the plaintext input. */}
|
{/* FNXC:McpConfig 2026-06-26-01:17: This picker is the only UI seam for MCP env/header secrets. Operators may create a secret value here, but the settings draft receives only { secretRef, scope }, never the plaintext input. */}
|
||||||
@@ -540,7 +574,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
|||||||
setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, scope: nextScope as SecretScope, secretRef: nextRef } : candidate) });
|
setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, scope: nextScope as SecretScope, secretRef: nextRef } : candidate) });
|
||||||
}}>
|
}}>
|
||||||
<option value={`${row.scope}:`}>{t("settings.mcp.chooseSecret", "Choose a secret…")}</option>
|
<option value={`${row.scope}:`}>{t("settings.mcp.chooseSecret", "Choose a secret…")}</option>
|
||||||
{secrets.map((secret) => <option key={`${secret.scope}:${secret.id}`} value={`${secret.scope}:${secret.id}`}>{secret.scope}: {secret.key}</option>)}
|
{visibleSecrets.map((secret) => <option key={`${secret.scope}:${secret.id}`} value={`${secret.scope}:${secret.id}`}>{secret.scope}: {secret.key}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input className="input" aria-label={t("settings.mcp.newSecretKey", "New secret key")} value={row.createKey} onChange={(event) => setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, createKey: event.target.value } : candidate) })} placeholder={t("settings.mcp.newSecretKey", "New secret key")} />
|
<input className="input" aria-label={t("settings.mcp.newSecretKey", "New secret key")} value={row.createKey} onChange={(event) => setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, createKey: event.target.value } : candidate) })} placeholder={t("settings.mcp.newSecretKey", "New secret key")} />
|
||||||
<input className="input" type="password" aria-label={t("settings.mcp.newSecretValue", "New secret value (not stored in settings)")} value={row.createValue} onChange={(event) => setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, createValue: event.target.value } : candidate) })} placeholder={t("settings.mcp.createSecretPlaceholder", "Create secret value")} />
|
<input className="input" type="password" aria-label={t("settings.mcp.newSecretValue", "New secret value (not stored in settings)")} value={row.createValue} onChange={(event) => setEditor((current) => current && { ...current, [field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, createValue: event.target.value } : candidate) })} placeholder={t("settings.mcp.createSecretPlaceholder", "Create secret value")} />
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import type { ToastType } from "../../../hooks/useToast";
|
|||||||
|
|
||||||
export interface SecretsSectionProps {
|
export interface SecretsSectionProps {
|
||||||
addToast: (message: string, type?: ToastType) => void;
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SecretsSection({ addToast }: SecretsSectionProps) {
|
export function SecretsSection({ addToast, projectId }: SecretsSectionProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4 className="settings-section-heading">{t("settings.nav.secrets", "Secrets")}</h4>
|
<h4 className="settings-section-heading">{t("settings.nav.secrets", "Secrets")}</h4>
|
||||||
<SecretsView addToast={addToast} />
|
<SecretsView addToast={addToast} projectId={projectId} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createApiRoutes } from "../../routes.js";
|
import { createApiRoutes } from "../../routes.js";
|
||||||
|
import { getProjectIdFromRequest } from "../context.js";
|
||||||
|
import { registerSecretsRoutes } from "../register-secrets-routes.js";
|
||||||
import { request } from "../../test-request.js";
|
import { request } from "../../test-request.js";
|
||||||
|
import type { ApiRoutesContext } from "../types.js";
|
||||||
|
|
||||||
const secretsStore = {
|
const secretsStore = {
|
||||||
listSecrets: vi.fn(),
|
listSecrets: vi.fn(),
|
||||||
@@ -49,12 +52,49 @@ function createStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function app() {
|
function app() {
|
||||||
|
const store = createStore();
|
||||||
const server = express();
|
const server = express();
|
||||||
server.use(express.json());
|
server.use(express.json());
|
||||||
server.use("/api", createApiRoutes(createStore()));
|
// Launch engine is deliberately fallback-capable: explicit project ids must select
|
||||||
|
// this intentional context, while missing ids are rejected before it is touched.
|
||||||
|
server.use("/api", createApiRoutes(store, {
|
||||||
|
engine: { getProjectId: () => "proj_A", getTaskStore: () => store },
|
||||||
|
} as any));
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scopedSecretsApp() {
|
||||||
|
const projectASecrets = { ...secretsStore, listSecrets: vi.fn(), createSecret: vi.fn(), updateSecret: vi.fn(), deleteSecret: vi.fn(), revealSecret: vi.fn() };
|
||||||
|
const projectBSecrets = { ...secretsStore, listSecrets: vi.fn(), createSecret: vi.fn(), updateSecret: vi.fn(), deleteSecret: vi.fn(), revealSecret: vi.fn() };
|
||||||
|
const projectAStore = { getSecretsStore: vi.fn(() => projectASecrets) };
|
||||||
|
const projectBStore = { getSecretsStore: vi.fn(() => projectBSecrets) };
|
||||||
|
const getProjectContext = vi.fn(async (req: express.Request) => {
|
||||||
|
const projectId = getProjectIdFromRequest(req);
|
||||||
|
// A call without explicit identity models the dangerous launch fallback this route must never reach.
|
||||||
|
if (!projectId) return { projectId: "local-launch-fallback", engine: undefined, store: { getSecretsStore: vi.fn() } };
|
||||||
|
return { projectId, engine: undefined, store: projectId === "proj_A" ? projectAStore : projectBStore };
|
||||||
|
});
|
||||||
|
const router = express.Router();
|
||||||
|
const logger = { child: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||||
|
logger.child.mockReturnValue(logger);
|
||||||
|
|
||||||
|
registerSecretsRoutes({
|
||||||
|
router,
|
||||||
|
runtimeLogger: logger,
|
||||||
|
getProjectIdFromRequest,
|
||||||
|
getProjectContext,
|
||||||
|
rethrowAsApiError: (error: unknown) => { throw error; },
|
||||||
|
} as unknown as ApiRoutesContext);
|
||||||
|
|
||||||
|
const server = express();
|
||||||
|
server.use(express.json());
|
||||||
|
server.use("/api", router);
|
||||||
|
server.use((error: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
res.status(error.statusCode ?? 500).json({ error: error.message ?? "Internal server error" });
|
||||||
|
});
|
||||||
|
return { server, getProjectContext, projectASecrets, projectBSecrets, projectAStore, projectBStore };
|
||||||
|
}
|
||||||
|
|
||||||
describe("register-secrets-routes", () => {
|
describe("register-secrets-routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -62,8 +102,94 @@ describe("register-secrets-routes", () => {
|
|||||||
secretsStore.updateSecret.mockResolvedValue({ id: "sec_1" });
|
secretsStore.updateSecret.mockResolvedValue({ id: "sec_1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["GET", "/api/secrets"],
|
||||||
|
["GET", "/api/secrets/sync-passphrase"],
|
||||||
|
["PUT", "/api/secrets/sync-passphrase"],
|
||||||
|
["DELETE", "/api/secrets/sync-passphrase"],
|
||||||
|
["POST", "/api/secrets"],
|
||||||
|
["PATCH", "/api/secrets/project/sec_1"],
|
||||||
|
["DELETE", "/api/secrets/project/sec_1"],
|
||||||
|
["POST", "/api/secrets/project/sec_1/reveal"],
|
||||||
|
])("rejects missing or blank project identity for %s %s before launch fallback store access", async (method, path) => {
|
||||||
|
/*
|
||||||
|
FNXC:Secrets 2026-08-05-22:08:
|
||||||
|
Every secrets endpoint must reject missing, empty, and whitespace-only identity before the launch-capable context resolver runs. Testing every shape prevents a query parser edge case from silently reopening the fallback partition.
|
||||||
|
*/
|
||||||
|
for (const suffix of ["", "?projectId=", "?projectId=%20%20%20"]) {
|
||||||
|
const res = await request(app(), method, `${path}${suffix}`, method === "PUT" || method === "POST" || method === "PATCH" ? JSON.stringify({ scope: "project", key: "K", value: "v" }) : undefined, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(String((res.body as any).error)).toContain("projectId is required");
|
||||||
|
}
|
||||||
|
expect(secretsStore.listSecrets).not.toHaveBeenCalled();
|
||||||
|
expect(secretsStore.createSecret).not.toHaveBeenCalled();
|
||||||
|
expect(secretsStore.updateSecret).not.toHaveBeenCalled();
|
||||||
|
expect(secretsStore.deleteSecret).not.toHaveBeenCalled();
|
||||||
|
expect(secretsStore.revealSecret).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["GET", "/api/secrets"],
|
||||||
|
["GET", "/api/secrets/sync-passphrase"],
|
||||||
|
["PUT", "/api/secrets/sync-passphrase"],
|
||||||
|
["DELETE", "/api/secrets/sync-passphrase"],
|
||||||
|
["POST", "/api/secrets"],
|
||||||
|
["PATCH", "/api/secrets/project/sec_1"],
|
||||||
|
["DELETE", "/api/secrets/project/sec_1"],
|
||||||
|
["POST", "/api/secrets/project/sec_1/reveal"],
|
||||||
|
])("fails closed before fallback-capable context resolution for %s %s", async (method, path) => {
|
||||||
|
/*
|
||||||
|
FNXC:Secrets 2026-08-05-22:28:
|
||||||
|
The route-local harness makes launch fallback observable. Missing, empty, and whitespace-only identities must return 400 without calling getProjectContext, so no fallback store can list, mutate, reveal, or configure secrets.
|
||||||
|
*/
|
||||||
|
const harness = scopedSecretsApp();
|
||||||
|
for (const suffix of ["", "?projectId=", "?projectId=%20%20%20"]) {
|
||||||
|
const res = await request(
|
||||||
|
harness.server,
|
||||||
|
method,
|
||||||
|
`${path}${suffix}`,
|
||||||
|
method === "PUT" || method === "POST" || method === "PATCH" ? JSON.stringify({ scope: "project", key: "K", value: "v" }) : undefined,
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((res.body as { error: string }).error).toBe("projectId is required");
|
||||||
|
}
|
||||||
|
expect(harness.getProjectContext).not.toHaveBeenCalled();
|
||||||
|
expect(harness.projectAStore.getSecretsStore).not.toHaveBeenCalled();
|
||||||
|
expect(harness.projectBStore.getSecretsStore).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects explicit project stores while global rows remain shared", async () => {
|
||||||
|
const harness = scopedSecretsApp();
|
||||||
|
const sharedGlobal = { id: "global-1", scope: "global", key: "SHARED" };
|
||||||
|
const projectAOnly = { id: "a-1", scope: "project", key: "A_ONLY" };
|
||||||
|
const projectBOnly = { id: "b-1", scope: "project", key: "B_ONLY" };
|
||||||
|
harness.projectASecrets.listSecrets.mockResolvedValue([projectAOnly, sharedGlobal]);
|
||||||
|
harness.projectBSecrets.listSecrets.mockResolvedValue([projectBOnly, sharedGlobal]);
|
||||||
|
harness.projectASecrets.createSecret.mockResolvedValue(sharedGlobal);
|
||||||
|
|
||||||
|
const created = await request(harness.server, "POST", "/api/secrets?projectId=proj_A", JSON.stringify({ scope: "global", key: "SHARED", value: "secret" }), { "Content-Type": "application/json" });
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
expect(harness.getProjectContext).toHaveBeenCalledTimes(1);
|
||||||
|
expect(harness.projectAStore.getSecretsStore).toHaveBeenCalledTimes(1);
|
||||||
|
expect(harness.projectBStore.getSecretsStore).not.toHaveBeenCalled();
|
||||||
|
expect(harness.projectASecrets.createSecret).toHaveBeenCalledWith(expect.objectContaining({ scope: "global", key: "SHARED" }));
|
||||||
|
|
||||||
|
const listedA = await request(harness.server, "GET", "/api/secrets?projectId=proj_A");
|
||||||
|
const listedB = await request(harness.server, "GET", "/api/secrets?projectId=proj_B");
|
||||||
|
expect(listedA.status).toBe(200);
|
||||||
|
expect(listedB.status).toBe(200);
|
||||||
|
expect((listedA.body as { secrets: Array<{ key: string }> }).secrets.map((secret) => secret.key)).toEqual(["A_ONLY", "SHARED"]);
|
||||||
|
expect((listedB.body as { secrets: Array<{ key: string }> }).secrets.map((secret) => secret.key)).toEqual(["B_ONLY", "SHARED"]);
|
||||||
|
expect(harness.projectAStore.getSecretsStore).toHaveBeenCalledTimes(2);
|
||||||
|
expect(harness.projectBStore.getSecretsStore).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("POST /api/secrets rejects non-string key", async () => {
|
it("POST /api/secrets rejects non-string key", async () => {
|
||||||
const res = await request(app(), "POST", "/api/secrets", JSON.stringify({ scope: "project", key: 123, value: "v" }), {
|
const res = await request(app(), "POST", "/api/secrets?projectId=proj_A", JSON.stringify({ scope: "project", key: 123, value: "v" }), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +198,7 @@ describe("register-secrets-routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("POST /api/secrets rejects non-string value", async () => {
|
it("POST /api/secrets rejects non-string value", async () => {
|
||||||
const res = await request(app(), "POST", "/api/secrets", JSON.stringify({ scope: "project", key: "K", value: 123 }), {
|
const res = await request(app(), "POST", "/api/secrets?projectId=proj_A", JSON.stringify({ scope: "project", key: "K", value: 123 }), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +207,7 @@ describe("register-secrets-routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("POST /api/secrets rejects invalid scope", async () => {
|
it("POST /api/secrets rejects invalid scope", async () => {
|
||||||
const res = await request(app(), "POST", "/api/secrets", JSON.stringify({ scope: "local", key: "K", value: "v" }), {
|
const res = await request(app(), "POST", "/api/secrets?projectId=proj_A", JSON.stringify({ scope: "local", key: "K", value: "v" }), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +219,7 @@ describe("register-secrets-routes", () => {
|
|||||||
const res = await request(
|
const res = await request(
|
||||||
app(),
|
app(),
|
||||||
"POST",
|
"POST",
|
||||||
"/api/secrets",
|
"/api/secrets?projectId=proj_A",
|
||||||
JSON.stringify({ scope: "project", key: "K", value: "v", accessPolicy: "banana" }),
|
JSON.stringify({ scope: "project", key: "K", value: "v", accessPolicy: "banana" }),
|
||||||
{
|
{
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -108,7 +234,7 @@ describe("register-secrets-routes", () => {
|
|||||||
const res = await request(
|
const res = await request(
|
||||||
app(),
|
app(),
|
||||||
"POST",
|
"POST",
|
||||||
"/api/secrets",
|
"/api/secrets?projectId=proj_A",
|
||||||
JSON.stringify({ scope: "global", key: "MY_KEY", value: "secret", accessPolicy: "deny" }),
|
JSON.stringify({ scope: "global", key: "MY_KEY", value: "secret", accessPolicy: "deny" }),
|
||||||
{
|
{
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -130,7 +256,7 @@ describe("register-secrets-routes", () => {
|
|||||||
const res = await request(
|
const res = await request(
|
||||||
app(),
|
app(),
|
||||||
"PATCH",
|
"PATCH",
|
||||||
"/api/secrets/project/sec_1",
|
"/api/secrets/project/sec_1?projectId=proj_A",
|
||||||
JSON.stringify({ accessPolicy: "banana" }),
|
JSON.stringify({ accessPolicy: "banana" }),
|
||||||
{ "Content-Type": "application/json" },
|
{ "Content-Type": "application/json" },
|
||||||
);
|
);
|
||||||
@@ -143,7 +269,7 @@ describe("register-secrets-routes", () => {
|
|||||||
const res = await request(
|
const res = await request(
|
||||||
app(),
|
app(),
|
||||||
"PATCH",
|
"PATCH",
|
||||||
"/api/secrets/project/sec_1",
|
"/api/secrets/project/sec_1?projectId=proj_A",
|
||||||
JSON.stringify({ accessPolicy: null }),
|
JSON.stringify({ accessPolicy: null }),
|
||||||
{ "Content-Type": "application/json" },
|
{ "Content-Type": "application/json" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Request } from "express";
|
||||||
import {
|
import {
|
||||||
RESERVED_SYNC_PASSPHRASE_KEY,
|
RESERVED_SYNC_PASSPHRASE_KEY,
|
||||||
clearSyncPassphrase,
|
clearSyncPassphrase,
|
||||||
@@ -38,6 +39,27 @@ function assertObject(value: unknown): asserts value is Record<string, unknown>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:Secrets 2026-08-05-21:37:
|
||||||
|
* Secret routes must reject absent explicit request identity before getProjectContext can resolve the daemon launch engine/store fallback. A selected project is required even for global scope, which remains stored in shared central.secrets_global.
|
||||||
|
*/
|
||||||
|
function getExplicitProjectId(req: Request, ctx: Parameters<ApiRouteRegistrar>[0]): string {
|
||||||
|
const projectId = ctx.getProjectIdFromRequest(req);
|
||||||
|
if (!projectId) throw badRequest("projectId is required");
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExplicitSecretsStore(req: Request, ctx: Parameters<ApiRouteRegistrar>[0]) {
|
||||||
|
const requestedProjectId = getExplicitProjectId(req, ctx);
|
||||||
|
const { store, projectId } = await ctx.getProjectContext(req);
|
||||||
|
/*
|
||||||
|
FNXC:Secrets 2026-08-05-22:08:
|
||||||
|
The explicit id check must happen before context resolution because a launch engine can otherwise substitute its own partition. Reject a resolver mismatch too, so a malformed context cannot redirect a scoped secret request after that precondition.
|
||||||
|
*/
|
||||||
|
if (projectId !== requestedProjectId) throw badRequest("project context does not match projectId");
|
||||||
|
return store.getSecretsStore();
|
||||||
|
}
|
||||||
|
|
||||||
function emitSecretsAudit(
|
function emitSecretsAudit(
|
||||||
req: unknown,
|
req: unknown,
|
||||||
ctx: Parameters<ApiRouteRegistrar>[0],
|
ctx: Parameters<ApiRouteRegistrar>[0],
|
||||||
@@ -55,12 +77,11 @@ function emitSecretsAudit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
||||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
const { router, rethrowAsApiError } = ctx;
|
||||||
|
|
||||||
router.get("/secrets", async (req, res) => {
|
router.get("/secrets", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||||
const secretsStore = await scopedStore.getSecretsStore();
|
|
||||||
const secrets = await secretsStore.listSecrets();
|
const secrets = await secretsStore.listSecrets();
|
||||||
const visibleSecrets = secrets.filter(
|
const visibleSecrets = secrets.filter(
|
||||||
(secret) => !(secret.scope === "global" && secret.key === RESERVED_SYNC_PASSPHRASE_KEY),
|
(secret) => !(secret.scope === "global" && secret.key === RESERVED_SYNC_PASSPHRASE_KEY),
|
||||||
@@ -75,8 +96,7 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
router.get("/secrets/sync-passphrase", async (req, res) => {
|
router.get("/secrets/sync-passphrase", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||||
const secretsStore = await scopedStore.getSecretsStore();
|
|
||||||
const configured = await hasSyncPassphraseConfigured(secretsStore);
|
const configured = await hasSyncPassphraseConfigured(secretsStore);
|
||||||
res.json({ configured });
|
res.json({ configured });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -88,14 +108,14 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
router.put("/secrets/sync-passphrase", async (req, res) => {
|
router.put("/secrets/sync-passphrase", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
getExplicitProjectId(req, ctx);
|
||||||
assertObject(req.body);
|
assertObject(req.body);
|
||||||
const passphrase = req.body.passphrase;
|
const passphrase = req.body.passphrase;
|
||||||
if (typeof passphrase !== "string" || passphrase.trim().length === 0) {
|
if (typeof passphrase !== "string" || passphrase.trim().length === 0) {
|
||||||
res.status(400).json({ error: "invalid-passphrase" });
|
res.status(400).json({ error: "invalid-passphrase" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||||
const secretsStore = await scopedStore.getSecretsStore();
|
|
||||||
const hadConfiguredPassphrase = await hasSyncPassphraseConfigured(secretsStore);
|
const hadConfiguredPassphrase = await hasSyncPassphraseConfigured(secretsStore);
|
||||||
await setSyncPassphrase(secretsStore, passphrase);
|
await setSyncPassphrase(secretsStore, passphrase);
|
||||||
emitSecretsAudit(req, ctx, hadConfiguredPassphrase ? "secret:update" : "secret:create", {
|
emitSecretsAudit(req, ctx, hadConfiguredPassphrase ? "secret:update" : "secret:create", {
|
||||||
@@ -112,8 +132,7 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
router.delete("/secrets/sync-passphrase", async (req, res) => {
|
router.delete("/secrets/sync-passphrase", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||||
const secretsStore = await scopedStore.getSecretsStore();
|
|
||||||
await clearSyncPassphrase(secretsStore);
|
await clearSyncPassphrase(secretsStore);
|
||||||
emitSecretsAudit(req, ctx, "secret:delete", { key: RESERVED_SYNC_PASSPHRASE_KEY, scope: "global" });
|
emitSecretsAudit(req, ctx, "secret:delete", { key: RESERVED_SYNC_PASSPHRASE_KEY, scope: "global" });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -126,6 +145,7 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
router.post("/secrets", async (req, res) => {
|
router.post("/secrets", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
getExplicitProjectId(req, ctx);
|
||||||
assertObject(req.body);
|
assertObject(req.body);
|
||||||
const { scope, key, value, description, accessPolicy, envExportable, envExportKey } = req.body;
|
const { scope, key, value, description, accessPolicy, envExportable, envExportKey } = req.body;
|
||||||
const parsedScope = parseScope(scope);
|
const parsedScope = parseScope(scope);
|
||||||
@@ -143,8 +163,7 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
parsedAccessPolicy = accessPolicy;
|
parsedAccessPolicy = accessPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||||
const secretsStore = await scopedStore.getSecretsStore();
|
|
||||||
const secret = await secretsStore.createSecret({
|
const secret = await secretsStore.createSecret({
|
||||||
scope: parsedScope,
|
scope: parsedScope,
|
||||||
key,
|
key,
|
||||||
@@ -164,6 +183,7 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
router.patch("/secrets/:scope/:id", async (req, res) => {
|
router.patch("/secrets/:scope/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
getExplicitProjectId(req, ctx);
|
||||||
const scope = parseScope(req.params.scope);
|
const scope = parseScope(req.params.scope);
|
||||||
const id = String(req.params.id ?? "").trim();
|
const id = String(req.params.id ?? "").trim();
|
||||||
if (!id) throw badRequest("id is required");
|
if (!id) throw badRequest("id is required");
|
||||||
@@ -209,8 +229,7 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||||
const secretsStore = await scopedStore.getSecretsStore();
|
|
||||||
const secret = await secretsStore.updateSecret(id, scope, patch);
|
const secret = await secretsStore.updateSecret(id, scope, patch);
|
||||||
res.json(secret);
|
res.json(secret);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -222,11 +241,11 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
router.delete("/secrets/:scope/:id", async (req, res) => {
|
router.delete("/secrets/:scope/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
getExplicitProjectId(req, ctx);
|
||||||
const scope = parseScope(req.params.scope);
|
const scope = parseScope(req.params.scope);
|
||||||
const id = String(req.params.id ?? "").trim();
|
const id = String(req.params.id ?? "").trim();
|
||||||
if (!id) throw badRequest("id is required");
|
if (!id) throw badRequest("id is required");
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||||
const secretsStore = await scopedStore.getSecretsStore();
|
|
||||||
await secretsStore.deleteSecret(id, scope);
|
await secretsStore.deleteSecret(id, scope);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -238,11 +257,11 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
router.post("/secrets/:scope/:id/reveal", async (req, res) => {
|
router.post("/secrets/:scope/:id/reveal", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
getExplicitProjectId(req, ctx);
|
||||||
const scope = parseScope(req.params.scope);
|
const scope = parseScope(req.params.scope);
|
||||||
const id = String(req.params.id ?? "").trim();
|
const id = String(req.params.id ?? "").trim();
|
||||||
if (!id) throw badRequest("id is required");
|
if (!id) throw badRequest("id is required");
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||||
const secretsStore = await scopedStore.getSecretsStore();
|
|
||||||
const secret = await secretsStore.revealSecret(id, scope, {
|
const secret = await secretsStore.revealSecret(id, scope, {
|
||||||
userId: null,
|
userId: null,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user