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 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`)
|
||||
|
||||
`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 { ViewHeader } from "./ViewHeader";
|
||||
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||
import { withProjectId } from "../api/client/health";
|
||||
|
||||
type ToastKind = "info" | "success" | "error";
|
||||
type SecretScope = "project" | "global";
|
||||
@@ -22,6 +23,7 @@ interface SecretRecord {
|
||||
|
||||
interface SecretsViewProps {
|
||||
addToast?: (msg: string, kind?: ToastKind) => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const RESERVED_SYNC_PASSPHRASE_KEY = "__sync_passphrase__";
|
||||
@@ -57,11 +59,13 @@ const spinningActionIconProps = {
|
||||
className: "secrets-action-icon spin",
|
||||
} as const;
|
||||
|
||||
export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
export const SecretsView = ({ addToast, projectId }: SecretsViewProps) => {
|
||||
const { t } = useTranslation("app");
|
||||
const [secrets, setSecrets] = useState<SecretRecord[]>([]);
|
||||
const [secretsProjectId, setSecretsProjectId] = useState<string | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorProjectId, setErrorProjectId] = useState<string | undefined>();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<SecretRecord | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -71,6 +75,7 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
const [revealedValues, setRevealedValues] = useState<Record<string, string | null>>({});
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [syncPassphraseConfigured, setSyncPassphraseConfigured] = useState(false);
|
||||
const [syncStatusProjectId, setSyncStatusProjectId] = useState<string | undefined>();
|
||||
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
||||
const [syncPassphrase, setSyncPassphrase] = useState("");
|
||||
const [syncPassphraseConfirm, setSyncPassphraseConfirm] = useState("");
|
||||
@@ -84,6 +89,20 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
const [syncDisclosureOpen, setSyncDisclosureOpen] = useState(false);
|
||||
const revealTimersRef = 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 response = await fetch(url, {
|
||||
@@ -102,35 +121,68 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
}, []);
|
||||
|
||||
const loadSecrets = useCallback(async () => {
|
||||
const requestProjectId = projectId;
|
||||
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||
const requestVersion = ++projectRequestVersionRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setErrorProjectId(undefined);
|
||||
try {
|
||||
const data = await request<{ secrets: SecretRecord[] }>("/api/secrets");
|
||||
setSecrets(data.secrets);
|
||||
const data = await request<{ secrets: SecretRecord[] }>(projectUrl("/api/secrets"));
|
||||
if (requestVersion === projectRequestVersionRef.current && activeProjectIdRef.current === requestProjectId) {
|
||||
setSecrets(data.secrets);
|
||||
setSecretsProjectId(requestProjectId);
|
||||
}
|
||||
} 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 {
|
||||
setLoading(false);
|
||||
if (requestVersion === projectRequestVersionRef.current && activeProjectIdRef.current === requestProjectId) setLoading(false);
|
||||
}
|
||||
}, [request]);
|
||||
}, [projectId, projectUrl, request]);
|
||||
|
||||
const loadSyncPassphraseStatus = useCallback(async () => {
|
||||
const requestProjectId = projectId;
|
||||
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||
const requestVersion = ++syncRequestVersionRef.current;
|
||||
try {
|
||||
const data = await request<{ configured: boolean }>("/api/secrets/sync-passphrase");
|
||||
setSyncPassphraseConfigured(Boolean(data.configured));
|
||||
const data = await request<{ configured: boolean }>(projectUrl("/api/secrets/sync-passphrase"));
|
||||
if (requestVersion === syncRequestVersionRef.current && activeProjectIdRef.current === requestProjectId) {
|
||||
setSyncPassphraseConfigured(Boolean(data.configured));
|
||||
setSyncStatusProjectId(requestProjectId);
|
||||
}
|
||||
} 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(() => {
|
||||
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 loadSyncPassphraseStatus();
|
||||
return () => {
|
||||
revealTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
copyTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
};
|
||||
}, [loadSecrets, loadSyncPassphraseStatus]);
|
||||
}, [loadSecrets, loadSyncPassphraseStatus, projectId]);
|
||||
|
||||
const closeSyncModal = () => {
|
||||
setSyncModalOpen(false);
|
||||
@@ -138,36 +190,44 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
setSyncPassphraseConfirm("");
|
||||
};
|
||||
|
||||
const saveSyncPassphrase = async (passphrase: string) => {
|
||||
await request<{ success: boolean }>("/api/secrets/sync-passphrase", {
|
||||
const saveSyncPassphrase = async (passphrase: string, requestProjectId: string | undefined) => {
|
||||
await request<{ success: boolean }>(withProjectId("/api/secrets/sync-passphrase", requestProjectId), {
|
||||
method: "PUT",
|
||||
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 requestProjectId = projectId;
|
||||
setSyncSaving(true);
|
||||
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");
|
||||
closeSyncModal();
|
||||
await loadSyncPassphraseStatus();
|
||||
} 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 {
|
||||
setSyncSaving(false);
|
||||
if (activeProjectIdRef.current === requestProjectId) setSyncSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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."));
|
||||
if (!confirmed) return;
|
||||
const requestProjectId = projectId;
|
||||
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");
|
||||
await loadSyncPassphraseStatus();
|
||||
} 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 requestProjectId = projectId;
|
||||
setFormError(null);
|
||||
try {
|
||||
if (editing) {
|
||||
@@ -207,12 +268,12 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
envExportKey: form.envExportable ? (form.envExportKey || null) : null,
|
||||
};
|
||||
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",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
await request("/api/secrets", {
|
||||
await request(projectUrl("/api/secrets"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
scope: form.scope,
|
||||
@@ -225,11 +286,12 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||
setShowModal(false);
|
||||
setForm(EMPTY_FORM);
|
||||
await loadSecrets();
|
||||
} 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 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 }));
|
||||
addToast?.(t("secrets.revealed", "Revealed"), "success");
|
||||
const timer = setTimeout(() => {
|
||||
@@ -279,16 +343,24 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
};
|
||||
|
||||
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);
|
||||
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(
|
||||
() => [...secrets]
|
||||
() => [...(secretsAreCurrent ? secrets : [])]
|
||||
.filter((secret) => !(secret.scope === "global" && secret.key === RESERVED_SYNC_PASSPHRASE_KEY))
|
||||
.sort((a, b) => a.key.localeCompare(b.key)),
|
||||
[secrets],
|
||||
[secrets, secretsAreCurrent],
|
||||
);
|
||||
|
||||
const syncPassphraseMatches = syncPassphrase.length > 0 && syncPassphrase === syncPassphraseConfirm;
|
||||
@@ -310,9 +382,9 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <div className="form-error">{error}</div> : null}
|
||||
{loading ? <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}
|
||||
{error && errorProjectId === projectId ? <div className="form-error">{error}</div> : null}
|
||||
{loading || !secretsAreCurrent ? <div className="secrets-loading"><RefreshCw {...spinningActionIconProps} /> {t("secrets.loading", "Loading…")}</div> : null}
|
||||
{!loading && secretsAreCurrent && sortedSecrets.length === 0 ? <div className="secrets-empty">{t("secrets.empty", "No secrets found.")}</div> : null}
|
||||
|
||||
<div className="secrets-list">
|
||||
{sortedSecrets.map((secret) => {
|
||||
@@ -383,10 +455,10 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
{syncDisclosureOpen ? (
|
||||
<div id="secrets-sync-disclosure-panel" className="secrets-sync-disclosure-panel">
|
||||
<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">
|
||||
<button className="btn" onClick={() => setSyncModalOpen(true)}>{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}
|
||||
<button className="btn" onClick={() => setSyncModalOpen(true)}>{syncStatusIsCurrent && syncPassphraseConfigured ? t("secrets.rotateSyncPassphrase", "Rotate") : t("secrets.setPassphrase", "Set passphrase")}</button>
|
||||
{syncStatusIsCurrent && syncPassphraseConfigured ? <button className="btn btn-danger" onClick={() => void clearSyncPassphraseHandler()}>{t("secrets.clearSyncPassphrase", "Clear")}</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="secrets-sync-copy">
|
||||
|
||||
@@ -4069,7 +4069,7 @@ export function SettingsModal({
|
||||
);
|
||||
|
||||
case "secrets":
|
||||
return <SecretsSection addToast={addToast} />;
|
||||
return <SecretsSection addToast={addToast} projectId={projectId} />;
|
||||
case "global-mcp":
|
||||
return (
|
||||
<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 () => {
|
||||
const removeCss = installCss(baseCss);
|
||||
seedFetchForSecretsList();
|
||||
|
||||
@@ -108,6 +108,143 @@ describe("SecretsView", () => {
|
||||
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 () => {
|
||||
vi.stubGlobal(
|
||||
"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;
|
||||
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")) {
|
||||
return new Response(JSON.stringify({ secrets: [secret] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (url === "/api/secrets" && init?.method === "POST") {
|
||||
if (url.startsWith("/api/secrets?") || url === "/api/secrets") {
|
||||
if (!init?.method || init.method === "GET") {
|
||||
return new Response(JSON.stringify({ secrets: [secret] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (init?.method === "POST") {
|
||||
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") {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
const addToast = vi.fn();
|
||||
function Harness() {
|
||||
@@ -85,6 +87,7 @@ function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalS
|
||||
scope={options.scope}
|
||||
form={form}
|
||||
globalSettings={options.globalSettings}
|
||||
projectId={options.projectId}
|
||||
pluginServers={options.pluginServers}
|
||||
addToast={addToast}
|
||||
setForm={(next) => {
|
||||
@@ -144,6 +147,60 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
render(<GlobalMcpSection form={{} as Settings} setForm={vi.fn()} addToast={vi.fn()} />);
|
||||
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) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/secrets")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ secrets: [] }),
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/api/secrets/sync-passphrase")) {
|
||||
if (url.startsWith("/api/secrets/sync-passphrase")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ configured: false }),
|
||||
};
|
||||
}
|
||||
if (url.startsWith("/api/secrets")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ secrets: [] }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
||||
@@ -684,7 +684,7 @@ export function MainContent({
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<SecretsView addToast={addToast} />
|
||||
<SecretsView addToast={addToast} projectId={currentProject?.id} />
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -255,7 +255,7 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
|
||||
label: "Secrets",
|
||||
icon: Lock,
|
||||
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",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withProjectId } from "../../../api/client/health";
|
||||
import {
|
||||
exportMcpServersJson,
|
||||
importMcpServersJson,
|
||||
@@ -267,6 +268,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
||||
const [editor, setEditor] = useState<EditorDraft | null>(null);
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
const [secrets, setSecrets] = useState<SecretRecord[]>([]);
|
||||
const [secretsProjectId, setSecretsProjectId] = useState<string | undefined>();
|
||||
const [secretsError, setSecretsError] = useState<string | null>(null);
|
||||
const [importText, setImportText] = useState("");
|
||||
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 [discoveryLoading, setDiscoveryLoading] = useState(false);
|
||||
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 requestProjectId = projectId;
|
||||
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||
const requestVersion = ++secretsRequestVersionRef.current;
|
||||
try {
|
||||
const data = await requestJson<{ secrets: SecretRecord[] }>("/api/secrets");
|
||||
setSecrets(data.secrets);
|
||||
setSecretsError(null);
|
||||
const data = await requestJson<{ secrets: SecretRecord[] }>(withProjectId("/api/secrets", requestProjectId));
|
||||
if (secretsRequestVersionRef.current === requestVersion && activeProjectIdRef.current === requestProjectId) {
|
||||
setSecrets(data.secrets);
|
||||
setSecretsProjectId(requestProjectId);
|
||||
setSecretsError(null);
|
||||
}
|
||||
} 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(() => {
|
||||
setSecrets([]);
|
||||
setSecretsProjectId(undefined);
|
||||
setSecretsError(null);
|
||||
setEditor(null);
|
||||
setEditorError(null);
|
||||
void reloadSecrets();
|
||||
}, [reloadSecrets]);
|
||||
|
||||
@@ -426,13 +449,14 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
||||
|
||||
const createSecretForRow = async (row: SensitiveRowDraft, field: "env" | "headers") => {
|
||||
if (!editor) return;
|
||||
const requestProjectId = projectId;
|
||||
const key = row.createKey.trim() || row.key.trim();
|
||||
if (!key || !row.createValue) {
|
||||
setEditorError(t("settings.mcp.secretCreateRequired", "Secret key and value are required."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const secret = await requestJson<SecretRecord>("/api/secrets", {
|
||||
const secret = await requestJson<SecretRecord>(withProjectId("/api/secrets", requestProjectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
scope: row.scope,
|
||||
@@ -444,14 +468,15 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
||||
envExportKey: null,
|
||||
}),
|
||||
});
|
||||
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||
setEditor((current) => current && {
|
||||
...current,
|
||||
[field]: current[field].map((candidate) => candidate.id === row.id ? { ...candidate, secretRef: secret.id, scope: secret.scope, createKey: secret.key, createValue: "" } : candidate),
|
||||
});
|
||||
await reloadSecrets();
|
||||
addToast(t("settings.mcp.secretCreated", "Secret created"), "success");
|
||||
if (activeProjectIdRef.current === requestProjectId) addToast(t("settings.mcp.secretCreated", "Secret created"), "success");
|
||||
} 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 }));
|
||||
return;
|
||||
}
|
||||
const requestProjectId = projectId;
|
||||
try {
|
||||
const refBySuggestedKey = new Map<string, McpSecretRef>();
|
||||
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",
|
||||
body: JSON.stringify({
|
||||
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 });
|
||||
}
|
||||
if (activeProjectIdRef.current !== requestProjectId) return;
|
||||
const definitions = result.definitions.map((server) => {
|
||||
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]));
|
||||
@@ -509,9 +537,9 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
||||
updateMcpSettings({ ...settings, enabled: true, servers: [...configuredServers, ...definitions] });
|
||||
setImportText("");
|
||||
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) {
|
||||
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[]) => (
|
||||
<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. */}
|
||||
@@ -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) });
|
||||
}}>
|
||||
<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>
|
||||
<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")} />
|
||||
|
||||
@@ -11,14 +11,15 @@ import type { ToastType } from "../../../hooks/useToast";
|
||||
|
||||
export interface SecretsSectionProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function SecretsSection({ addToast }: SecretsSectionProps) {
|
||||
export function SecretsSection({ addToast, projectId }: SecretsSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<>
|
||||
<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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { getProjectIdFromRequest } from "../context.js";
|
||||
import { registerSecretsRoutes } from "../register-secrets-routes.js";
|
||||
import { request } from "../../test-request.js";
|
||||
import type { ApiRoutesContext } from "../types.js";
|
||||
|
||||
const secretsStore = {
|
||||
listSecrets: vi.fn(),
|
||||
@@ -49,12 +52,49 @@ function createStore() {
|
||||
}
|
||||
|
||||
function app() {
|
||||
const store = createStore();
|
||||
const server = express();
|
||||
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;
|
||||
}
|
||||
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -62,8 +102,94 @@ describe("register-secrets-routes", () => {
|
||||
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 () => {
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -72,7 +198,7 @@ describe("register-secrets-routes", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -81,7 +207,7 @@ describe("register-secrets-routes", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -93,7 +219,7 @@ describe("register-secrets-routes", () => {
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/secrets",
|
||||
"/api/secrets?projectId=proj_A",
|
||||
JSON.stringify({ scope: "project", key: "K", value: "v", accessPolicy: "banana" }),
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
@@ -108,7 +234,7 @@ describe("register-secrets-routes", () => {
|
||||
const res = await request(
|
||||
app(),
|
||||
"POST",
|
||||
"/api/secrets",
|
||||
"/api/secrets?projectId=proj_A",
|
||||
JSON.stringify({ scope: "global", key: "MY_KEY", value: "secret", accessPolicy: "deny" }),
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
@@ -130,7 +256,7 @@ describe("register-secrets-routes", () => {
|
||||
const res = await request(
|
||||
app(),
|
||||
"PATCH",
|
||||
"/api/secrets/project/sec_1",
|
||||
"/api/secrets/project/sec_1?projectId=proj_A",
|
||||
JSON.stringify({ accessPolicy: "banana" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
@@ -143,7 +269,7 @@ describe("register-secrets-routes", () => {
|
||||
const res = await request(
|
||||
app(),
|
||||
"PATCH",
|
||||
"/api/secrets/project/sec_1",
|
||||
"/api/secrets/project/sec_1?projectId=proj_A",
|
||||
JSON.stringify({ accessPolicy: null }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Request } from "express";
|
||||
import {
|
||||
RESERVED_SYNC_PASSPHRASE_KEY,
|
||||
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(
|
||||
req: unknown,
|
||||
ctx: Parameters<ApiRouteRegistrar>[0],
|
||||
@@ -55,12 +77,11 @@ function emitSecretsAudit(
|
||||
}
|
||||
|
||||
export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||
const { router, rethrowAsApiError } = ctx;
|
||||
|
||||
router.get("/secrets", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const secretsStore = await scopedStore.getSecretsStore();
|
||||
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||
const secrets = await secretsStore.listSecrets();
|
||||
const visibleSecrets = secrets.filter(
|
||||
(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) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const secretsStore = await scopedStore.getSecretsStore();
|
||||
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||
const configured = await hasSyncPassphraseConfigured(secretsStore);
|
||||
res.json({ configured });
|
||||
} catch (err: unknown) {
|
||||
@@ -88,14 +108,14 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
router.put("/secrets/sync-passphrase", async (req, res) => {
|
||||
try {
|
||||
getExplicitProjectId(req, ctx);
|
||||
assertObject(req.body);
|
||||
const passphrase = req.body.passphrase;
|
||||
if (typeof passphrase !== "string" || passphrase.trim().length === 0) {
|
||||
res.status(400).json({ error: "invalid-passphrase" });
|
||||
return;
|
||||
}
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const secretsStore = await scopedStore.getSecretsStore();
|
||||
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||
const hadConfiguredPassphrase = await hasSyncPassphraseConfigured(secretsStore);
|
||||
await setSyncPassphrase(secretsStore, passphrase);
|
||||
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) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const secretsStore = await scopedStore.getSecretsStore();
|
||||
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||
await clearSyncPassphrase(secretsStore);
|
||||
emitSecretsAudit(req, ctx, "secret:delete", { key: RESERVED_SYNC_PASSPHRASE_KEY, scope: "global" });
|
||||
res.json({ success: true });
|
||||
@@ -126,6 +145,7 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
router.post("/secrets", async (req, res) => {
|
||||
try {
|
||||
getExplicitProjectId(req, ctx);
|
||||
assertObject(req.body);
|
||||
const { scope, key, value, description, accessPolicy, envExportable, envExportKey } = req.body;
|
||||
const parsedScope = parseScope(scope);
|
||||
@@ -143,8 +163,7 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
parsedAccessPolicy = accessPolicy;
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const secretsStore = await scopedStore.getSecretsStore();
|
||||
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||
const secret = await secretsStore.createSecret({
|
||||
scope: parsedScope,
|
||||
key,
|
||||
@@ -164,6 +183,7 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
router.patch("/secrets/:scope/:id", async (req, res) => {
|
||||
try {
|
||||
getExplicitProjectId(req, ctx);
|
||||
const scope = parseScope(req.params.scope);
|
||||
const id = String(req.params.id ?? "").trim();
|
||||
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 scopedStore.getSecretsStore();
|
||||
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||
const secret = await secretsStore.updateSecret(id, scope, patch);
|
||||
res.json(secret);
|
||||
} catch (err: unknown) {
|
||||
@@ -222,11 +241,11 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
router.delete("/secrets/:scope/:id", async (req, res) => {
|
||||
try {
|
||||
getExplicitProjectId(req, ctx);
|
||||
const scope = parseScope(req.params.scope);
|
||||
const id = String(req.params.id ?? "").trim();
|
||||
if (!id) throw badRequest("id is required");
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const secretsStore = await scopedStore.getSecretsStore();
|
||||
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||
await secretsStore.deleteSecret(id, scope);
|
||||
res.status(204).send();
|
||||
} catch (err: unknown) {
|
||||
@@ -238,11 +257,11 @@ export const registerSecretsRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
router.post("/secrets/:scope/:id/reveal", async (req, res) => {
|
||||
try {
|
||||
getExplicitProjectId(req, ctx);
|
||||
const scope = parseScope(req.params.scope);
|
||||
const id = String(req.params.id ?? "").trim();
|
||||
if (!id) throw badRequest("id is required");
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const secretsStore = await scopedStore.getSecretsStore();
|
||||
const secretsStore = await getExplicitSecretsStore(req, ctx);
|
||||
const secret = await secretsStore.revealSecret(id, scope, {
|
||||
userId: null,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user