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:
gsxdsm
2026-08-05 15:41:56 -07:00
parent 2a0827835d
commit 00fdbe1bef
14 changed files with 555 additions and 87 deletions

View 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.

View File

@@ -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`.

View File

@@ -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">

View File

@@ -4069,7 +4069,7 @@ export function SettingsModal({
);
case "secrets":
return <SecretsSection addToast={addToast} />;
return <SecretsSection addToast={addToast} projectId={projectId} />;
case "global-mcp":
return (
<GlobalMcpSection

View File

@@ -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();

View File

@@ -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",

View File

@@ -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();

View File

@@ -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,

View File

@@ -684,7 +684,7 @@ export function MainContent({
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<SecretsView addToast={addToast} />
<SecretsView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);

View File

@@ -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",

View File

@@ -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")} />

View File

@@ -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} />
</>
);
}

View File

@@ -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" },
);

View File

@@ -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,
});