fix(ai): mask apiKey exposure, add audit logging, resolve masked-key UX regression

This commit is contained in:
Yash Kumar
2026-09-21 01:47:19 +05:30
parent a7d8378f52
commit 2bfcac58b0
3 changed files with 52 additions and 9 deletions
@@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => {
{
apiUrl: apiUrl ?? "",
apiKey: apiKey ?? "",
...(aiId ? { aiId } : {}),
},
{
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
@@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => {
apiUrl={apiUrl}
apiKey={apiKey}
model={form.watch("model")}
aiId={aiId}
/>
<Button type="submit" isLoading={isPending}>
{aiId ? "Update" : "Create"}
@@ -498,10 +500,12 @@ function TestConnectionButton({
apiUrl,
apiKey,
model,
aiId,
}: {
apiUrl: string;
apiKey: string;
model: string;
aiId?: string;
}) {
const { mutate, isPending } = api.ai.testConnection.useMutation({
onSuccess: () => {
@@ -521,7 +525,9 @@ function TestConnectionButton({
type="button"
variant="outline"
disabled={isDisabled || isPending}
onClick={() => mutate({ apiUrl, apiKey, model })}
onClick={() =>
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+27 -7
View File
@@ -17,6 +17,7 @@ import {
getCustomAiProviders,
maskAiApiKey,
mergeAiApiKey,
resolveAiApiKey,
saveAiSettings,
saveCustomAiProviders,
suggestVariants,
@@ -58,11 +59,23 @@ export const aiRouter = createTRPCRouter({
}),
getModels: withAnyPermission("ai", ["read", "create", "update"])
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
.query(async ({ input }) => {
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
aiId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
try {
const apiKey = await resolveAiApiKey(
input.apiKey,
input.apiUrl,
input.aiId,
ctx.session.activeOrganizationId,
);
const providerName = getProviderName(input.apiUrl);
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
const headers = getProviderHeaders(input.apiUrl, apiKey);
let response = null;
switch (providerName) {
case "ollama":
@@ -70,7 +83,7 @@ export const aiRouter = createTRPCRouter({
break;
case "gemini":
response = await fetch(
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
{ headers: {} },
);
break;
@@ -133,7 +146,7 @@ export const aiRouter = createTRPCRouter({
},
] as Model[];
default:
if (!input.apiKey)
if (!apiKey)
throw new TRPCError({
code: "BAD_REQUEST",
message: "API key must contain at least 1 character(s)",
@@ -335,13 +348,20 @@ ${input.logs}`,
apiUrl: z.string().min(1),
apiKey: z.string(),
model: z.string().min(1),
aiId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const apiKey = await resolveAiApiKey(
input.apiKey,
input.apiUrl,
input.aiId,
ctx.session.activeOrganizationId,
);
const provider = selectAIProvider({
apiUrl: input.apiUrl,
apiKey: input.apiKey,
apiKey,
});
const model = provider(input.model);
const result = await generateText({
+18 -1
View File
@@ -108,7 +108,24 @@ export const saveCustomAiProviders = async (
return providers;
};
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const resolveAiApiKey = async (
apiKey: string,
apiUrl: string,
aiId: string | undefined,
organizationId: string,
): Promise<string> => {
if (apiKey !== AI_SECRET_MASK || !aiId) return apiKey;
const stored = await getAiSettingById(aiId, organizationId);
if (normalizeApiUrl(stored.apiUrl) !== normalizeApiUrl(apiUrl)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "API URL does not match the stored configuration",
});
}
return stored.apiKey;
};
export const saveAiSettings = async (organizationId: string, settings: any) => {
const aiId = settings.aiId;