diff --git a/.changeset/fn-8808-secrets-project-binding.md b/.changeset/fn-8808-secrets-project-binding.md new file mode 100644 index 0000000000..b151e14b9f --- /dev/null +++ b/.changeset/fn-8808-secrets-project-binding.md @@ -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. diff --git a/docs/secrets.md b/docs/secrets.md index b154be07a7..182121ecc6 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -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`. diff --git a/packages/dashboard/app/components/SecretsView.tsx b/packages/dashboard/app/components/SecretsView.tsx index 42fdcd955a..7a1b84fa91 100644 --- a/packages/dashboard/app/components/SecretsView.tsx +++ b/packages/dashboard/app/components/SecretsView.tsx @@ -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([]); + const [secretsProjectId, setSecretsProjectId] = useState(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [errorProjectId, setErrorProjectId] = useState(); const [formError, setFormError] = useState(null); const [editing, setEditing] = useState(null); const [showModal, setShowModal] = useState(false); @@ -71,6 +75,7 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => { const [revealedValues, setRevealedValues] = useState>({}); const [copiedId, setCopiedId] = useState(null); const [syncPassphraseConfigured, setSyncPassphraseConfigured] = useState(false); + const [syncStatusProjectId, setSyncStatusProjectId] = useState(); 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>>(new Map()); const copyTimersRef = useRef>>(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 (url: string, init?: RequestInit): Promise => { 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 ?
{error}
: null} - {loading ?
{t("secrets.loading", "Loading…")}
: null} - {!loading && sortedSecrets.length === 0 ?
{t("secrets.empty", "No secrets found.")}
: null} + {error && errorProjectId === projectId ?
{error}
: null} + {loading || !secretsAreCurrent ?
{t("secrets.loading", "Loading…")}
: null} + {!loading && secretsAreCurrent && sortedSecrets.length === 0 ?
{t("secrets.empty", "No secrets found.")}
: null}
{sortedSecrets.map((secret) => { @@ -383,10 +455,10 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => { {syncDisclosureOpen ? (
-

+

- - {syncPassphraseConfigured ? : null} + + {syncStatusIsCurrent && syncPassphraseConfigured ? : null}

diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index ae110de01c..8a91d43d16 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -4069,7 +4069,7 @@ export function SettingsModal({ ); case "secrets": - return ; + return ; case "global-mcp": return ( { }, ); + it("uses the selected project for mobile initial requests", async () => { + const fetchMock = seedFetchForSecretsList(); + render(); + + 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(); diff --git a/packages/dashboard/app/components/__tests__/SecretsView.test.tsx b/packages/dashboard/app/components/__tests__/SecretsView.test.tsx index 536b2dccf9..dc8605566b 100644 --- a/packages/dashboard/app/components/__tests__/SecretsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/SecretsView.test.tsx @@ -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(); + + 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((resolve) => { resolveA = resolve; }); + if (url === "/api/secrets?projectId=proj_B") return new Promise((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(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj_A", expect.anything())); + + rerender(); + 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((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(); + 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(); + 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(); + 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", diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx index b01ff568af..08575d88d1 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.mcp.test.tsx @@ -48,12 +48,14 @@ function mockFetch(statusByName: Record | null; pluginServers?: Array<{ pluginId: string; server: { name: string; transport: "stdio"; command: string; enabledByDefault?: boolean } }> }) { +function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalSettings?: Pick | 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((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((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(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/secrets?projectId=proj_A", expect.anything())); + + rerender(); + 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(); expect(await screen.findByTestId("mcp-servers-card-global")).toBeInTheDocument(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx index 3a760569bd..24af68815a 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx @@ -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, diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 6517e5f9c1..7c1cc8bae5 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -684,7 +684,7 @@ export function MainContent({ return ( - + ); diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index ee5b41d134..ad3333bd30 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -255,7 +255,7 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [ label: "Secrets", icon: Lock, testId: "right-dock-tab-secrets", - render: (props) => wrapOverflowView(), + render: (props) => wrapOverflowView(), }, { key: "pull-requests", diff --git a/packages/dashboard/app/components/settings/sections/McpServersCard.tsx b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx index 17f762caa3..eb656c5de6 100644 --- a/packages/dashboard/app/components/settings/sections/McpServersCard.tsx +++ b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx @@ -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(null); const [editorError, setEditorError] = useState(null); const [secrets, setSecrets] = useState([]); + const [secretsProjectId, setSecretsProjectId] = useState(); const [secretsError, setSecretsError] = useState(null); const [importText, setImportText] = useState(""); const [importError, setImportError] = useState(null); @@ -275,18 +277,39 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId const [discovered, setDiscovered] = useState(null); const [discoveryLoading, setDiscoveryLoading] = useState(false); const [discoveryError, setDiscoveryError] = useState(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("/api/secrets", { + const secret = await requestJson(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(); for (const descriptor of result.secretsToCreate) { - const secret = await requestJson("/api/secrets", { + if (activeProjectIdRef.current !== requestProjectId) return; + const secret = await requestJson(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[]) => (

{/* 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) }); }}> - {secrets.map((secret) => )} + {visibleSecrets.map((secret) => )} 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")} /> 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")} /> diff --git a/packages/dashboard/app/components/settings/sections/SecretsSection.tsx b/packages/dashboard/app/components/settings/sections/SecretsSection.tsx index 477523dec5..a4b501d6f6 100644 --- a/packages/dashboard/app/components/settings/sections/SecretsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/SecretsSection.tsx @@ -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 ( <>

{t("settings.nav.secrets", "Secrets")}

- + ); } diff --git a/packages/dashboard/src/routes/__tests__/register-secrets-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-secrets-routes.test.ts index 3f467bc98d..9e6d710ed8 100644 --- a/packages/dashboard/src/routes/__tests__/register-secrets-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-secrets-routes.test.ts @@ -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" }, ); diff --git a/packages/dashboard/src/routes/register-secrets-routes.ts b/packages/dashboard/src/routes/register-secrets-routes.ts index 8dea2e91c8..dcce630d92 100644 --- a/packages/dashboard/src/routes/register-secrets-routes.ts +++ b/packages/dashboard/src/routes/register-secrets-routes.ts @@ -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 } } +/** + * 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[0]): string { + const projectId = ctx.getProjectIdFromRequest(req); + if (!projectId) throw badRequest("projectId is required"); + return projectId; +} + +async function getExplicitSecretsStore(req: Request, ctx: Parameters[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[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, });