From 28430dde74f399cf78a7c1db57610f53b153ec18 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 31 Jul 2026 17:36:04 -0400 Subject: [PATCH] add targets and refactor endpoints --- server/db/pg/schema/schema.ts | 20 +- server/db/sqlite/schema/schema.ts | 15 +- server/lib/aiProviderDefaults.ts | 27 ++- server/lib/alerts/events/healthCheckEvents.ts | 8 +- server/lib/deleteResource.ts | 9 +- server/lib/deleteSiteAssociatedResources.ts | 8 +- .../integration/verifyApiKeyAiModelAccess.ts | 27 +-- .../verifyApiKeyAiProviderAccess.ts | 48 +---- .../integration/verifyApiKeyTargetAccess.ts | 80 +++++--- server/middlewares/verifyAiModelAccess.ts | 28 +-- server/middlewares/verifyAiProviderAccess.ts | 19 +- server/middlewares/verifyTargetAccess.ts | 127 +++++++----- server/routers/aiProvider/createAiModel.ts | 12 +- server/routers/aiProvider/createAiProvider.ts | 11 +- server/routers/aiProvider/deleteAiModel.ts | 31 +-- server/routers/aiProvider/deleteAiProvider.ts | 21 +- server/routers/aiProvider/getAiModel.ts | 27 +-- server/routers/aiProvider/getAiProvider.ts | 14 +- server/routers/aiProvider/listAiModels.ts | 12 +- server/routers/aiProvider/types.ts | 4 +- server/routers/aiProvider/updateAiModel.ts | 34 +-- server/routers/aiProvider/updateAiProvider.ts | 89 ++++---- server/routers/aiProvider/validation.ts | 21 +- server/routers/external.ts | 41 ++-- server/routers/integration.ts | 40 ++-- server/routers/newt/buildConfiguration.ts | 16 +- server/routers/target/createTarget.ts | 194 +++++++++++++++--- server/routers/target/deleteTarget.ts | 80 +++++--- server/routers/target/listTargets.ts | 61 +++++- server/routers/target/updateTarget.ts | 106 ++++++++-- .../public/ProxyResourceTargetsForm.tsx | 1 + 31 files changed, 741 insertions(+), 490 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 43a84ab5d..0f59b1c26 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -323,11 +323,18 @@ export const targets = pgTable( "targets", { targetId: serial("targetId").primaryKey(), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { + resourceId: integer("resourceId").references( + () => resources.resourceId, + { onDelete: "cascade" - }) - .notNull(), + } + ), + providerId: integer("providerId").references( + () => aiProviders.providerId, + { + onDelete: "cascade" + } + ), siteId: integer("siteId") .references(() => sites.siteId, { onDelete: "cascade" @@ -351,6 +358,7 @@ export const targets = pgTable( }, (t) => [ index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId), + index("idx_targets_providerid_siteid").on(t.providerId, t.siteId), index("idx_targets_site_enabled_priority_target_resource") .on(t.siteId, t.priority.desc(), t.targetId, t.resourceId) .where(sql`${t.enabled} = true`) @@ -1572,6 +1580,10 @@ export const aiProviders = pgTable("aiProviders", { apiKey: text("apiKey"), apiKeyLastChars: varchar("apiKeyLastChars"), authType: varchar("authType").$type<"bearer">(), + routingMode: varchar("routingMode") + .$type<"url" | "target">() + .notNull() + .default("url"), skipTlsVerification: boolean("skipTlsVerification") .notNull() .default(false), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 8265a2855..73e73f04a 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -326,11 +326,12 @@ export const clientLabels = sqliteTable( export const targets = sqliteTable("targets", { targetId: integer("targetId").primaryKey({ autoIncrement: true }), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), + resourceId: integer("resourceId").references(() => resources.resourceId, { + onDelete: "cascade" + }), + providerId: integer("providerId").references(() => aiProviders.providerId, { + onDelete: "cascade" + }), siteId: integer("siteId") .references(() => sites.siteId, { onDelete: "cascade" @@ -1561,6 +1562,10 @@ export const aiProviders = sqliteTable("aiProviders", { apiKey: text("apiKey"), apiKeyLastChars: text("apiKeyLastChars"), authType: text("authType").$type<"bearer">(), + routingMode: text("routingMode") + .$type<"url" | "target">() + .notNull() + .default("url"), skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" }) .notNull() .default(false), diff --git a/server/lib/aiProviderDefaults.ts b/server/lib/aiProviderDefaults.ts index 63c060b62..6cfb816b7 100644 --- a/server/lib/aiProviderDefaults.ts +++ b/server/lib/aiProviderDefaults.ts @@ -11,6 +11,7 @@ export type AiProviderType = export type AiProviderAuthType = "bearer"; export type AiBudgetUnit = "usd" | "tokens"; +export type AiProviderRoutingMode = "url" | "target"; type AiProviderDefaults = { upstreamUrl: string | null; @@ -55,7 +56,13 @@ export const AI_PROVIDER_DEFAULTS: Record< } }; -export function providerRequiresUpstreamUrl(type: AiProviderType): boolean { +export function providerRequiresUpstreamUrl( + type: AiProviderType, + routingMode: AiProviderRoutingMode = "url" +): boolean { + if (routingMode === "target") { + return false; + } if (type === "custom") { return true; } @@ -66,20 +73,34 @@ export function resolveAiProviderConfig(input: { type: AiProviderType; upstreamUrl: string | null; authType: AiProviderAuthType | null; + routingMode?: AiProviderRoutingMode | null; }): { upstreamUrl: string | null; authType: AiProviderAuthType | null; + routingMode: AiProviderRoutingMode; } { + const routingMode = input.routingMode ?? "url"; + + if (routingMode === "target") { + return { + upstreamUrl: null, + authType: input.authType ?? "bearer", + routingMode + }; + } + if (input.type === "custom") { return { upstreamUrl: input.upstreamUrl, - authType: input.authType + authType: input.authType, + routingMode }; } const defaults = AI_PROVIDER_DEFAULTS[input.type]; return { upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl, - authType: input.authType ?? defaults.authType + authType: input.authType ?? defaults.authType, + routingMode }; } diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts index 429cf73b0..b7b5a8ff2 100644 --- a/server/lib/alerts/events/healthCheckEvents.ts +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -202,6 +202,10 @@ async function handleResource( return; } + if (!target.resourceId) { + return; + } + const [resource] = await trx .select() .from(resources) @@ -227,9 +231,7 @@ async function handleResource( let health = "healthy"; const allUnknown = monitoredTargets.length === 0; - const allHealthy = monitoredTargets.every( - (t) => t.hcHealth === "healthy" - ); + const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy"); const allUnhealthy = monitoredTargets.every( (t) => t.hcHealth === "unhealthy" ); diff --git a/server/lib/deleteResource.ts b/server/lib/deleteResource.ts index 3f33c7400..b71818b17 100644 --- a/server/lib/deleteResource.ts +++ b/server/lib/deleteResource.ts @@ -64,13 +64,20 @@ export async function performDeleteResources( const targetsByResourceId = new Map(); for (const target of targetsToBeRemoved) { + if (target.resourceId == null) { + continue; + } const existing = targetsByResourceId.get(target.resourceId) ?? []; existing.push(target); targetsByResourceId.set(target.resourceId, existing); } const targetIdToResourceId = new Map( - targetsToBeRemoved.map((target) => [target.targetId, target.resourceId]) + targetsToBeRemoved.flatMap((target) => + target.resourceId == null + ? [] + : [[target.targetId, target.resourceId] as const] + ) ); const healthChecksByResourceId = new Map(); diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts index e69585d1a..c0e520846 100644 --- a/server/lib/deleteSiteAssociatedResources.ts +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, sql } from "drizzle-orm"; import { db, resources, @@ -33,9 +33,11 @@ export async function getResourceIdsForSite( const rows = await trx .selectDistinct({ resourceId: targets.resourceId }) .from(targets) - .where(eq(targets.siteId, siteId)); + .where(and(eq(targets.siteId, siteId), isNotNull(targets.resourceId))); - return rows.map((row) => row.resourceId); + return rows + .map((row) => row.resourceId) + .filter((resourceId): resourceId is number => resourceId != null); } export async function getSiteResourceIdsForSite( diff --git a/server/middlewares/integration/verifyApiKeyAiModelAccess.ts b/server/middlewares/integration/verifyApiKeyAiModelAccess.ts index 75a56f37e..eec772ac3 100644 --- a/server/middlewares/integration/verifyApiKeyAiModelAccess.ts +++ b/server/middlewares/integration/verifyApiKeyAiModelAccess.ts @@ -14,9 +14,6 @@ export async function verifyApiKeyAiModelAccess( const apiKey = req.apiKey; const modelIdRaw = getFirstString(req.params.modelId); const modelId = Number.parseInt(modelIdRaw ?? "", 10); - const providerIdRaw = getFirstString(req.params.providerId); - const providerId = Number.parseInt(providerIdRaw ?? "", 10); - const orgId = getFirstString(req.params.orgId); if (!apiKey) { return next( @@ -24,18 +21,6 @@ export async function verifyApiKeyAiModelAccess( ); } - if (!orgId) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); - } - - if (Number.isNaN(providerId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") - ); - } - if (Number.isNaN(modelId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID") @@ -52,13 +37,7 @@ export async function verifyApiKeyAiModelAccess( aiProviders, eq(aiModels.providerId, aiProviders.providerId) ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiModels.modelId, modelId)) .limit(1); if (!row) { @@ -76,7 +55,9 @@ export async function verifyApiKeyAiModelAccess( return next(); } - if (!req.apiKeyOrg) { + const orgId = row.provider.orgId; + + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) diff --git a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts index 6b9f9b0ec..1668b5727 100644 --- a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts +++ b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts @@ -14,7 +14,6 @@ export async function verifyApiKeyAiProviderAccess( const apiKey = req.apiKey; const providerIdRaw = getFirstString(req.params.providerId); const providerId = Number.parseInt(providerIdRaw ?? "", 10); - const orgId = getFirstString(req.params.orgId); if (!apiKey) { return next( @@ -22,52 +21,16 @@ export async function verifyApiKeyAiProviderAccess( ); } - if (!orgId) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); - } - if (Number.isNaN(providerId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") ); } - if (apiKey.isRoot) { - const [provider] = await db - .select() - .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) - .limit(1); - - if (!provider) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `AI provider with ID ${providerId} not found` - ) - ); - } - - req.aiProvider = provider; - return next(); - } - const [provider] = await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { @@ -79,7 +42,14 @@ export async function verifyApiKeyAiProviderAccess( ); } - if (!req.apiKeyOrg) { + if (apiKey.isRoot) { + req.aiProvider = provider; + return next(); + } + + const orgId = provider.orgId; + + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) diff --git a/server/middlewares/integration/verifyApiKeyTargetAccess.ts b/server/middlewares/integration/verifyApiKeyTargetAccess.ts index dac36b1e2..98d5349df 100644 --- a/server/middlewares/integration/verifyApiKeyTargetAccess.ts +++ b/server/middlewares/integration/verifyApiKeyTargetAccess.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; -import { resources, targets, apiKeyOrg } from "@server/db"; +import { aiProviders, resources, targets, apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -43,43 +43,65 @@ export async function verifyApiKeyTargetAccess( ); } - const resourceId = target.resourceId; - if (!resourceId) { + const { resourceId, providerId } = target; + if ((!resourceId && !providerId) || (resourceId && providerId)) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - `Target with ID ${targetId} does not have a resource ID` - ) - ); - } - - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)) - .limit(1); - - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` + `Target with ID ${targetId} has invalid ownership` ) ); } if (apiKey.isRoot) { - // Root keys can access any key in any org + // Root keys can access any target return next(); } - if (!resource.orgId) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - `Resource with ID ${resourceId} does not have an organization ID` - ) - ); + let orgId: string; + if (resourceId) { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + orgId = resource.orgId; + } else { + const [provider] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId!)) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + orgId = provider.orgId; } if (!req.apiKeyOrg) { @@ -89,7 +111,7 @@ export async function verifyApiKeyTargetAccess( .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), - eq(apiKeyOrg.orgId, resource.orgId) + eq(apiKeyOrg.orgId, orgId) ) ) .limit(1); @@ -98,7 +120,7 @@ export async function verifyApiKeyTargetAccess( } } - if (!req.apiKeyOrg) { + if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/verifyAiModelAccess.ts b/server/middlewares/verifyAiModelAccess.ts index 99693b982..0d5761fc4 100644 --- a/server/middlewares/verifyAiModelAccess.ts +++ b/server/middlewares/verifyAiModelAccess.ts @@ -16,9 +16,6 @@ export async function verifyAiModelAccess( const userId = req.user!.userId; const modelIdRaw = getFirstString(req.params.modelId); const modelId = Number.parseInt(modelIdRaw ?? "", 10); - const providerIdRaw = getFirstString(req.params.providerId); - const providerId = Number.parseInt(providerIdRaw ?? "", 10); - const orgId = getFirstString(req.params.orgId); if (!userId) { return next( @@ -26,18 +23,6 @@ export async function verifyAiModelAccess( ); } - if (!orgId) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); - } - - if (Number.isNaN(providerId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") - ); - } - if (Number.isNaN(modelId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid model ID") @@ -54,13 +39,7 @@ export async function verifyAiModelAccess( aiProviders, eq(aiModels.providerId, aiProviders.providerId) ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiModels.modelId, modelId)) .limit(1); if (!row) { @@ -72,7 +51,9 @@ export async function verifyAiModelAccess( ); } - if (!req.userOrg) { + const orgId = row.provider.orgId; + + if (!req.userOrg || req.userOrg.orgId !== orgId) { const userOrgRole = await db .select() .from(userOrgs) @@ -109,6 +90,7 @@ export async function verifyAiModelAccess( } } + req.userOrgId = orgId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); req.aiProvider = row.provider; req.aiModel = row.model; diff --git a/server/middlewares/verifyAiProviderAccess.ts b/server/middlewares/verifyAiProviderAccess.ts index 604e3e893..22edd6f86 100644 --- a/server/middlewares/verifyAiProviderAccess.ts +++ b/server/middlewares/verifyAiProviderAccess.ts @@ -16,7 +16,6 @@ export async function verifyAiProviderAccess( const userId = req.user!.userId; const providerIdRaw = getFirstString(req.params.providerId); const providerId = Number.parseInt(providerIdRaw ?? "", 10); - const orgId = getFirstString(req.params.orgId); if (!userId) { return next( @@ -24,12 +23,6 @@ export async function verifyAiProviderAccess( ); } - if (!orgId) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); - } - if (Number.isNaN(providerId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") @@ -39,12 +32,7 @@ export async function verifyAiProviderAccess( const [provider] = await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { @@ -56,7 +44,9 @@ export async function verifyAiProviderAccess( ); } - if (!req.userOrg) { + const orgId = provider.orgId; + + if (!req.userOrg || req.userOrg.orgId !== orgId) { const userOrgRole = await db .select() .from(userOrgs) @@ -93,6 +83,7 @@ export async function verifyAiProviderAccess( } } + req.userOrgId = orgId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); req.aiProvider = provider; diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 24b8abd22..b495cf0e9 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; -import { resources, targets, userOrgs } from "@server/db"; +import { aiProviders, resources, targets, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -25,9 +25,7 @@ export async function verifyTargetAccess( } if (isNaN(targetId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") - ); + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")); } const target = await db @@ -45,73 +43,88 @@ export async function verifyTargetAccess( ); } - const resourceId = target[0].resourceId; + const { resourceId, providerId } = target[0]; - if (!resourceId) { + if ((!resourceId && !providerId) || (resourceId && providerId)) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - `Target with ID ${targetId} does not have a resource ID` + `Target with ID ${targetId} has invalid ownership` ) ); } try { - const resource = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId!)) - .limit(1); + let orgId: string; - if (resource.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); - } + if (resourceId) { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); - if (!resource[0].orgId) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - `resource with ID ${resourceId} does not have an organization ID` - ) - ); + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + orgId = resource.orgId; + } else { + const [provider] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId!)) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + orgId = provider.orgId; } if (!req.userOrg) { - const res = await db + const userOrgResult = await db .select() .from(userOrgs) .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, resource[0].orgId) - ) + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) ); - req.userOrg = res[0]; + req.userOrg = userOrgResult[0]; } - if (!req.userOrg) { - next( + if (!req.userOrg || req.userOrg.orgId !== orgId) { + return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); - } else { - req.userOrgRoleIds = await getUserOrgRoleIds( - req.userOrg.userId, - resource[0].orgId! - ); - req.userOrgId = resource[0].orgId!; } - const orgId = req.userOrg.orgId; + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); + req.userOrgId = orgId; - if (req.orgPolicyAllowed === undefined && orgId) { + if (req.orgPolicyAllowed === undefined) { const policyCheck = await checkOrgAccessPolicy({ orgId, userId, @@ -128,22 +141,24 @@ export async function verifyTargetAccess( } } - const resourceAllowed = await canUserAccessResource({ - userId, - resourceId, - roleIds: req.userOrgRoleIds ?? [] - }); + if (resourceId) { + const resourceAllowed = await canUserAccessResource({ + userId, + resourceId, + roleIds: req.userOrgRoleIds ?? [] + }); - if (!resourceAllowed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "User does not have access to this resource" - ) - ); + if (!resourceAllowed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this resource" + ) + ); + } } - next(); + return next(); } catch (e) { return next( createHttpError( diff --git a/server/routers/aiProvider/createAiModel.ts b/server/routers/aiProvider/createAiModel.ts index a770a5ed8..7fda989d2 100644 --- a/server/routers/aiProvider/createAiModel.ts +++ b/server/routers/aiProvider/createAiModel.ts @@ -15,7 +15,6 @@ import { } from "@server/routers/aiProvider/validation"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); @@ -33,7 +32,7 @@ const bodySchema = z registry.registerPath({ method: "put", - path: "/org/{orgId}/ai-provider/{providerId}/model", + path: "/ai-provider/{providerId}/model", description: "Create an AI model under a provider.", tags: [OpenAPITags.AiModel], request: { @@ -79,7 +78,7 @@ export async function createAiModel( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const { modelKey, name, budgetAmount, budgetUnit, enabled } = parsedBody.data; @@ -89,12 +88,7 @@ export async function createAiModel( : await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { diff --git a/server/routers/aiProvider/createAiProvider.ts b/server/routers/aiProvider/createAiProvider.ts index 27420bd34..947810ee1 100644 --- a/server/routers/aiProvider/createAiProvider.ts +++ b/server/routers/aiProvider/createAiProvider.ts @@ -15,6 +15,7 @@ import { aiAuthTypeSchema, aiBudgetUnitSchema, aiProviderTypeSchema, + aiRoutingModeSchema, refineBudgetFields, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; @@ -30,6 +31,7 @@ const bodySchema = z upstreamUrl: z.url().optional().nullable(), apiKey: z.string().optional(), authType: aiAuthTypeSchema.optional().nullable(), + routingMode: aiRoutingModeSchema.optional(), skipTlsVerification: z.boolean().optional(), budgetAmount: z.number().positive().optional().nullable(), budgetUnit: aiBudgetUnitSchema.optional().nullable(), @@ -95,6 +97,7 @@ export async function createAiProvider( upstreamUrl, apiKey, authType, + routingMode, skipTlsVerification, budgetAmount, budgetUnit, @@ -105,6 +108,8 @@ export async function createAiProvider( const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null; const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null; const now = Date.now(); + const resolvedRoutingMode = + type === "custom" ? (routingMode ?? "url") : "url"; const [provider] = await db .insert(aiProviders) @@ -112,10 +117,14 @@ export async function createAiProvider( orgId, name, type, - upstreamUrl: upstreamUrl ?? null, + upstreamUrl: + resolvedRoutingMode === "target" + ? null + : (upstreamUrl ?? null), apiKey: encryptedApiKey, apiKeyLastChars, authType: authType ?? null, + routingMode: resolvedRoutingMode, skipTlsVerification: skipTlsVerification ?? false, budgetAmount: budgetAmount ?? null, budgetUnit: budgetUnit ?? null, diff --git a/server/routers/aiProvider/deleteAiModel.ts b/server/routers/aiProvider/deleteAiModel.ts index be784275e..e9c58da79 100644 --- a/server/routers/aiProvider/deleteAiModel.ts +++ b/server/routers/aiProvider/deleteAiModel.ts @@ -1,23 +1,21 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { aiModels, aiProviders, db } from "@server/db"; +import { aiModels, db } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - providerId: z.coerce.number().int().positive(), modelId: z.coerce.number().int().positive() }); registry.registerPath({ method: "delete", - path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + path: "/ai-model/{modelId}", description: "Delete an AI model.", tags: [OpenAPITags.AiModel], request: { @@ -46,22 +44,12 @@ export async function deleteAiModel( ); } - const { orgId, providerId, modelId } = parsedParams.data; + const { modelId } = parsedParams.data; const [existing] = await db .select({ modelId: aiModels.modelId }) .from(aiModels) - .innerJoin( - aiProviders, - eq(aiModels.providerId, aiProviders.providerId) - ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiModels.modelId, modelId)) .limit(1); if (!existing) { @@ -73,14 +61,7 @@ export async function deleteAiModel( ); } - await db - .delete(aiModels) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId) - ) - ); + await db.delete(aiModels).where(eq(aiModels.modelId, modelId)); return response(res, { data: null, diff --git a/server/routers/aiProvider/deleteAiProvider.ts b/server/routers/aiProvider/deleteAiProvider.ts index b5416c23c..86a606b7a 100644 --- a/server/routers/aiProvider/deleteAiProvider.ts +++ b/server/routers/aiProvider/deleteAiProvider.ts @@ -7,16 +7,15 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); registry.registerPath({ method: "delete", - path: "/org/{orgId}/ai-provider/{providerId}", + path: "/ai-provider/{providerId}", description: "Delete an AI provider.", tags: [OpenAPITags.AiProvider], request: { @@ -45,17 +44,12 @@ export async function deleteAiProvider( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const [existing] = await db .select({ providerId: aiProviders.providerId }) .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!existing) { @@ -69,12 +63,7 @@ export async function deleteAiProvider( await db .delete(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ); + .where(eq(aiProviders.providerId, providerId)); return response(res, { data: null, diff --git a/server/routers/aiProvider/getAiModel.ts b/server/routers/aiProvider/getAiModel.ts index 6ea8795ee..45ffbe0ee 100644 --- a/server/routers/aiProvider/getAiModel.ts +++ b/server/routers/aiProvider/getAiModel.ts @@ -1,24 +1,22 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { aiModels, aiProviders, db } from "@server/db"; +import { aiModels, db } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { GetAiModelResponse } from "@server/routers/aiProvider/types"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - providerId: z.coerce.number().int().positive(), modelId: z.coerce.number().int().positive() }); registry.registerPath({ method: "get", - path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + path: "/ai-model/{modelId}", description: "Get an AI model by ID.", tags: [OpenAPITags.AiModel], request: { @@ -47,27 +45,16 @@ export async function getAiModel( ); } - const { orgId, providerId, modelId } = parsedParams.data; + const { modelId } = parsedParams.data; const [model] = req.aiModel && req.aiModel.modelId === modelId ? [req.aiModel] : await db - .select({ model: aiModels }) + .select() .from(aiModels) - .innerJoin( - aiProviders, - eq(aiModels.providerId, aiProviders.providerId) - ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) - .limit(1) - .then((rows) => rows.map((r) => r.model)); + .where(eq(aiModels.modelId, modelId)) + .limit(1); if (!model) { return next( diff --git a/server/routers/aiProvider/getAiProvider.ts b/server/routers/aiProvider/getAiProvider.ts index e2abd3948..32a337c85 100644 --- a/server/routers/aiProvider/getAiProvider.ts +++ b/server/routers/aiProvider/getAiProvider.ts @@ -7,18 +7,17 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { GetAiProviderResponse } from "@server/routers/aiProvider/types"; import { toPublicAiProvider } from "@server/routers/aiProvider/types"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); registry.registerPath({ method: "get", - path: "/org/{orgId}/ai-provider/{providerId}", + path: "/ai-provider/{providerId}", description: "Get an AI provider by ID.", tags: [OpenAPITags.AiProvider], request: { @@ -47,7 +46,7 @@ export async function getAiProvider( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const [provider] = req.aiProvider && req.aiProvider.providerId === providerId @@ -55,12 +54,7 @@ export async function getAiProvider( : await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { diff --git a/server/routers/aiProvider/listAiModels.ts b/server/routers/aiProvider/listAiModels.ts index a7794efca..753d51499 100644 --- a/server/routers/aiProvider/listAiModels.ts +++ b/server/routers/aiProvider/listAiModels.ts @@ -11,7 +11,6 @@ import { and, asc, eq, like, sql } from "drizzle-orm"; import type { ListAiModelsResponse } from "@server/routers/aiProvider/types"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); @@ -45,7 +44,7 @@ const listSchema = z.object({ registry.registerPath({ method: "get", - path: "/org/{orgId}/ai-provider/{providerId}/models", + path: "/ai-provider/{providerId}/models", description: "List AI models for a provider.", tags: [OpenAPITags.AiModel], request: { @@ -85,7 +84,7 @@ export async function listAiModels( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const [provider] = req.aiProvider && req.aiProvider.providerId === providerId @@ -93,12 +92,7 @@ export async function listAiModels( : await db .select({ providerId: aiProviders.providerId }) .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!provider) { diff --git a/server/routers/aiProvider/types.ts b/server/routers/aiProvider/types.ts index 4b8461b5e..2a6047e23 100644 --- a/server/routers/aiProvider/types.ts +++ b/server/routers/aiProvider/types.ts @@ -3,6 +3,7 @@ import type { PaginatedResponse } from "@server/types/Pagination"; import { resolveAiProviderConfig, type AiProviderAuthType, + type AiProviderRoutingMode, type AiProviderType } from "@server/lib/aiProviderDefaults"; @@ -40,7 +41,8 @@ export function toPublicAiProvider(provider: AiProvider): AiProviderPublic { const resolved = resolveAiProviderConfig({ type: provider.type as AiProviderType, upstreamUrl: provider.upstreamUrl, - authType: provider.authType as AiProviderAuthType | null + authType: provider.authType as AiProviderAuthType | null, + routingMode: provider.routingMode as AiProviderRoutingMode | null }); return { diff --git a/server/routers/aiProvider/updateAiModel.ts b/server/routers/aiProvider/updateAiModel.ts index ad09ee823..fa94e8da4 100644 --- a/server/routers/aiProvider/updateAiModel.ts +++ b/server/routers/aiProvider/updateAiModel.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { aiModels, aiProviders, db } from "@server/db"; +import { aiModels, db } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -15,8 +15,6 @@ import { } from "@server/routers/aiProvider/validation"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - providerId: z.coerce.number().int().positive(), modelId: z.coerce.number().int().positive() }); @@ -34,7 +32,7 @@ const bodySchema = z registry.registerPath({ method: "post", - path: "/org/{orgId}/ai-provider/{providerId}/model/{modelId}", + path: "/ai-model/{modelId}", description: "Update an AI model.", tags: [OpenAPITags.AiModel], request: { @@ -80,28 +78,17 @@ export async function updateAiModel( ); } - const { orgId, providerId, modelId } = parsedParams.data; + const { modelId } = parsedParams.data; const body = parsedBody.data; const [existing] = req.aiModel && req.aiModel.modelId === modelId ? [req.aiModel] : await db - .select({ model: aiModels }) + .select() .from(aiModels) - .innerJoin( - aiProviders, - eq(aiModels.providerId, aiProviders.providerId) - ) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) - .limit(1) - .then((rows) => rows.map((r) => r.model)); + .where(eq(aiModels.modelId, modelId)) + .limit(1); if (!existing) { return next( @@ -121,7 +108,7 @@ export async function updateAiModel( .from(aiModels) .where( and( - eq(aiModels.providerId, providerId), + eq(aiModels.providerId, existing.providerId), eq(aiModels.modelKey, body.modelKey), ne(aiModels.modelId, modelId) ) @@ -161,12 +148,7 @@ export async function updateAiModel( const [model] = await db .update(aiModels) .set(updateData) - .where( - and( - eq(aiModels.modelId, modelId), - eq(aiModels.providerId, providerId) - ) - ) + .where(eq(aiModels.modelId, modelId)) .returning(); return response(res, { diff --git a/server/routers/aiProvider/updateAiProvider.ts b/server/routers/aiProvider/updateAiProvider.ts index 186b5bcd8..494e459f6 100644 --- a/server/routers/aiProvider/updateAiProvider.ts +++ b/server/routers/aiProvider/updateAiProvider.ts @@ -7,7 +7,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types"; @@ -16,16 +16,16 @@ import { aiAuthTypeSchema, aiBudgetUnitSchema, aiProviderTypeSchema, + aiRoutingModeSchema, refineBudgetFields, refineProviderUpstreamFields } from "@server/routers/aiProvider/validation"; -import { - providerRequiresUpstreamUrl, - type AiProviderType +import type { + AiProviderRoutingMode, + AiProviderType } from "@server/lib/aiProviderDefaults"; const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), providerId: z.coerce.number().int().positive() }); @@ -35,6 +35,7 @@ const bodySchema = z upstreamUrl: z.url().optional().nullable(), apiKey: z.string().optional(), authType: aiAuthTypeSchema.optional().nullable(), + routingMode: aiRoutingModeSchema.optional(), skipTlsVerification: z.boolean().optional(), budgetAmount: z.number().positive().optional().nullable(), budgetUnit: aiBudgetUnitSchema.optional().nullable(), @@ -46,7 +47,7 @@ const bodySchema = z registry.registerPath({ method: "post", - path: "/org/{orgId}/ai-provider/{providerId}", + path: "/ai-provider/{providerId}", description: "Update an AI provider.", tags: [OpenAPITags.AiProvider], request: { @@ -92,7 +93,7 @@ export async function updateAiProvider( ); } - const { orgId, providerId } = parsedParams.data; + const { providerId } = parsedParams.data; const body = parsedBody.data; const [existing] = @@ -101,12 +102,7 @@ export async function updateAiProvider( : await db .select() .from(aiProviders) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .limit(1); if (!existing) { @@ -119,6 +115,11 @@ export async function updateAiProvider( } const providerType = existing.type as AiProviderType; + const nextRoutingMode: AiProviderRoutingMode = + providerType === "custom" + ? ((body.routingMode ?? + existing.routingMode) as AiProviderRoutingMode) + : "url"; const nextUpstreamUrl = body.upstreamUrl !== undefined ? body.upstreamUrl @@ -126,38 +127,33 @@ export async function updateAiProvider( const nextAuthType = body.authType !== undefined ? body.authType : existing.authType; - if ( - providerRequiresUpstreamUrl(providerType) || - body.upstreamUrl !== undefined || - body.authType !== undefined - ) { - const validation = z - .object({ - type: aiProviderTypeSchema, - upstreamUrl: z.string().nullable().optional(), - authType: aiAuthTypeSchema.nullable().optional() - }) - .superRefine((data, ctx) => - refineProviderUpstreamFields(data, ctx) - ) - .safeParse({ - type: providerType, - upstreamUrl: nextUpstreamUrl, - authType: nextAuthType - }); + const validation = z + .object({ + type: aiProviderTypeSchema, + upstreamUrl: z.string().nullable().optional(), + authType: aiAuthTypeSchema.nullable().optional(), + routingMode: aiRoutingModeSchema.optional() + }) + .superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx)) + .safeParse({ + type: providerType, + upstreamUrl: nextUpstreamUrl, + authType: nextAuthType, + routingMode: nextRoutingMode + }); - if (!validation.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(validation.error).toString() - ) - ); - } + if (!validation.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(validation.error).toString() + ) + ); } const updateData: Partial = { - updatedAt: Date.now() + updatedAt: Date.now(), + routingMode: nextRoutingMode }; if (body.name !== undefined) { @@ -175,7 +171,9 @@ export async function updateAiProvider( if (body.budgetUnit !== undefined) { updateData.budgetUnit = body.budgetUnit; } - if (body.upstreamUrl !== undefined) { + if (nextRoutingMode === "target") { + updateData.upstreamUrl = null; + } else if (body.upstreamUrl !== undefined) { updateData.upstreamUrl = body.upstreamUrl; } if (body.authType !== undefined) { @@ -191,12 +189,7 @@ export async function updateAiProvider( const [provider] = await db .update(aiProviders) .set(updateData) - .where( - and( - eq(aiProviders.providerId, providerId), - eq(aiProviders.orgId, orgId) - ) - ) + .where(eq(aiProviders.providerId, providerId)) .returning(); return response(res, { diff --git a/server/routers/aiProvider/validation.ts b/server/routers/aiProvider/validation.ts index ab70800b5..a0b0aa684 100644 --- a/server/routers/aiProvider/validation.ts +++ b/server/routers/aiProvider/validation.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { providerRequiresUpstreamUrl, type AiBudgetUnit, + type AiProviderRoutingMode, type AiProviderType } from "@server/lib/aiProviderDefaults"; @@ -21,6 +22,8 @@ export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]); export const aiAuthTypeSchema = z.enum(["bearer"]); +export const aiRoutingModeSchema = z.enum(["url", "target"]); + export function refineBudgetFields( data: { budgetAmount?: number | null; @@ -47,10 +50,24 @@ export function refineProviderUpstreamFields( type: AiProviderType; upstreamUrl?: string | null; authType?: "bearer" | null; + routingMode?: AiProviderRoutingMode | null; }, ctx: z.RefinementCtx ) { - if (providerRequiresUpstreamUrl(data.type) && !data.upstreamUrl) { + const routingMode = data.routingMode ?? "url"; + + if (data.type !== "custom" && routingMode === "target") { + ctx.addIssue({ + code: "custom", + message: "routingMode target is only allowed for custom providers", + path: ["routingMode"] + }); + } + + if ( + providerRequiresUpstreamUrl(data.type, routingMode) && + !data.upstreamUrl + ) { ctx.addIssue({ code: "custom", message: `upstreamUrl is required for ${data.type} providers`, @@ -58,7 +75,7 @@ export function refineProviderUpstreamFields( }); } - if (data.type === "custom" && !data.authType) { + if (data.type === "custom" && routingMode === "url" && !data.authType) { ctx.addIssue({ code: "custom", message: "authType is required for custom providers", diff --git a/server/routers/external.ts b/server/routers/external.ts index 1d33d6166..99bca3a8e 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1385,16 +1385,31 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/ai-provider/:providerId", - verifyOrgAccess, + "/ai-provider/:providerId", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.getAiProvider), aiProvider.getAiProvider ); +authenticated.put( + "/ai-provider/:providerId/target", + verifyAiProviderAccess, + verifySiteAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createTarget), + logActionAudit(ActionsEnum.createTarget), + target.createTarget +); + +authenticated.get( + "/ai-provider/:providerId/targets", + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.listTargets), + target.listTargets +); + authenticated.post( - "/org/:orgId/ai-provider/:providerId", - verifyOrgAccess, + "/ai-provider/:providerId", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.updateAiProvider), logActionAudit(ActionsEnum.updateAiProvider), @@ -1402,8 +1417,7 @@ authenticated.post( ); authenticated.delete( - "/org/:orgId/ai-provider/:providerId", - verifyOrgAccess, + "/ai-provider/:providerId", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.deleteAiProvider), logActionAudit(ActionsEnum.deleteAiProvider), @@ -1411,8 +1425,7 @@ authenticated.delete( ); authenticated.put( - "/org/:orgId/ai-provider/:providerId/model", - verifyOrgAccess, + "/ai-provider/:providerId/model", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.createAiModel), logActionAudit(ActionsEnum.createAiModel), @@ -1420,24 +1433,21 @@ authenticated.put( ); authenticated.get( - "/org/:orgId/ai-provider/:providerId/models", - verifyOrgAccess, + "/ai-provider/:providerId/models", verifyAiProviderAccess, verifyUserHasAction(ActionsEnum.listAiModels), aiProvider.listAiModels ); authenticated.get( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyOrgAccess, + "/ai-model/:modelId", verifyAiModelAccess, verifyUserHasAction(ActionsEnum.getAiModel), aiProvider.getAiModel ); authenticated.post( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyOrgAccess, + "/ai-model/:modelId", verifyAiModelAccess, verifyUserHasAction(ActionsEnum.updateAiModel), logActionAudit(ActionsEnum.updateAiModel), @@ -1445,8 +1455,7 @@ authenticated.post( ); authenticated.delete( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyOrgAccess, + "/ai-model/:modelId", verifyAiModelAccess, verifyUserHasAction(ActionsEnum.deleteAiModel), logActionAudit(ActionsEnum.deleteAiModel), diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 3a17079c6..384214e4c 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1386,16 +1386,30 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/ai-provider/:providerId", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.getAiProvider), aiProvider.getAiProvider ); +authenticated.put( + "/ai-provider/:providerId/target", + verifyApiKeyAiProviderAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createTarget), + logActionAudit(ActionsEnum.createTarget), + target.createTarget +); + +authenticated.get( + "/ai-provider/:providerId/targets", + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.listTargets), + target.listTargets +); + authenticated.post( - "/org/:orgId/ai-provider/:providerId", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.updateAiProvider), logActionAudit(ActionsEnum.updateAiProvider), @@ -1403,8 +1417,7 @@ authenticated.post( ); authenticated.delete( - "/org/:orgId/ai-provider/:providerId", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.deleteAiProvider), logActionAudit(ActionsEnum.deleteAiProvider), @@ -1412,8 +1425,7 @@ authenticated.delete( ); authenticated.put( - "/org/:orgId/ai-provider/:providerId/model", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId/model", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.createAiModel), logActionAudit(ActionsEnum.createAiModel), @@ -1421,24 +1433,21 @@ authenticated.put( ); authenticated.get( - "/org/:orgId/ai-provider/:providerId/models", - verifyApiKeyOrgAccess, + "/ai-provider/:providerId/models", verifyApiKeyAiProviderAccess, verifyApiKeyHasAction(ActionsEnum.listAiModels), aiProvider.listAiModels ); authenticated.get( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyApiKeyOrgAccess, + "/ai-model/:modelId", verifyApiKeyAiModelAccess, verifyApiKeyHasAction(ActionsEnum.getAiModel), aiProvider.getAiModel ); authenticated.post( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyApiKeyOrgAccess, + "/ai-model/:modelId", verifyApiKeyAiModelAccess, verifyApiKeyHasAction(ActionsEnum.updateAiModel), logActionAudit(ActionsEnum.updateAiModel), @@ -1446,8 +1455,7 @@ authenticated.post( ); authenticated.delete( - "/org/:orgId/ai-provider/:providerId/model/:modelId", - verifyApiKeyOrgAccess, + "/ai-model/:modelId", verifyApiKeyAiModelAccess, verifyApiKeyHasAction(ActionsEnum.deleteAiModel), logActionAudit(ActionsEnum.deleteAiModel), diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index ee33a2c94..e1782fd40 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -15,7 +15,7 @@ import { } from "@server/db"; import logger from "@server/logger"; import { initPeerAddHandshake, updatePeer } from "../olm/peers"; -import { eq, and, inArray } from "drizzle-orm"; +import { eq, and, inArray, or, isNotNull, sql } from "drizzle-orm"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { @@ -211,7 +211,8 @@ export async function buildClientConfigurationForNewtClient( // call rather than letting each resource fetch its own — with thousands // of resources this avoids a concurrent DB/cache stampede for what is // often the very same (e.g. wildcard) certificate. - const certByDomain = await batchFetchCertsForSiteResources(allSiteResources); + const certByDomain = + await batchFetchCertsForSiteResources(allSiteResources); const resourceTargetsArr = await Promise.all( allSiteResources.map((resource) => @@ -240,7 +241,7 @@ export async function buildTargetConfigurationForNewtClient( version?: string | null, remoteExitNodeId?: string ) { - // Get all enabled targets with their resource mode information + // Get enabled HTTP/TCP/UDP targets for resources and AI providers const allTargets = await db .select({ resourceId: targets.resourceId, @@ -250,15 +251,18 @@ export async function buildTargetConfigurationForNewtClient( port: targets.port, internalPort: targets.internalPort, enabled: targets.enabled, - mode: resources.mode + mode: sql`COALESCE(${resources.mode}, ${targets.mode})`.mapWith( + String + ) }) .from(targets) - .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .leftJoin(resources, eq(targets.resourceId, resources.resourceId)) .where( and( eq(targets.siteId, siteId), eq(targets.enabled, true), - inArray(targets.mode, ["http", "udp", "tcp"]) + inArray(targets.mode, ["http", "udp", "tcp"]), + or(isNotNull(targets.resourceId), isNotNull(targets.providerId)) ) ); diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 84fa00cec..fbcaa8439 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -6,7 +6,14 @@ import { TargetHealthCheck, targetHealthCheck } from "@server/db"; -import { newts, resources, sites, Target, targets } from "@server/db"; +import { + aiProviders, + newts, + resources, + sites, + Target, + targets +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -29,10 +36,19 @@ import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; -const createTargetParamsSchema = z.strictObject({ +const resourceTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); +const providerTargetParamsSchema = z.strictObject({ + providerId: z.coerce.number().int().positive() +}); + +const createTargetParamsSchema = z.union([ + resourceTargetParamsSchema, + providerTargetParamsSchema +]); + const createTargetSchema = z .strictObject({ siteId: z.int().positive(), @@ -95,7 +111,7 @@ registry.registerPath({ description: "Create a target for a resource.", tags: [OpenAPITags.PublicResourceLegacy], request: { - params: createTargetParamsSchema, + params: resourceTargetParamsSchema, body: { content: { "application/json": { @@ -128,7 +144,40 @@ registry.registerPath({ description: "Create a target for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { - params: createTargetParamsSchema, + params: resourceTargetParamsSchema, + body: { + content: { + "application/json": { + schema: createTargetSchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "put", + path: "/ai-provider/{providerId}/target", + description: "Create a target for an AI provider.", + tags: [OpenAPITags.AiProvider], + request: { + params: providerTargetParamsSchema, body: { content: { "application/json": { @@ -183,21 +232,74 @@ export async function createTarget( ); } - const { resourceId } = parsedParams.data; + let resource: typeof resources.$inferSelect | undefined; + let provider: typeof aiProviders.$inferSelect | undefined; - // get the resource - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)); + if ("providerId" in parsedParams.data) { + const { providerId } = parsedParams.data; + [provider] = + req.aiProvider && req.aiProvider.providerId === providerId + ? [req.aiProvider] + : await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${providerId} not found` + ) + ); + } + + if (provider.routingMode !== "target") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI provider must use target routing mode" + ) + ); + } + + if (provider.type !== "custom") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Only custom AI providers support targets" + ) + ); + } + + if ( + targetData.method && + !["http", "https"].includes(targetData.method.toLowerCase()) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI provider target method must be http or https" + ) + ); + } + } else { + const { resourceId } = parsedParams.data; + [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } } const siteId = targetData.siteId; @@ -217,6 +319,24 @@ export async function createTarget( ); } + if (provider && site.orgId && site.orgId !== provider.orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Site must belong to the AI provider organization" + ) + ); + } + + const resourceId = resource?.resourceId ?? null; + const providerId = provider?.providerId ?? null; + const targetMode = provider + ? "http" + : (targetData.mode ?? resource?.mode ?? "http"); + const targetMethod = provider + ? (targetData.method?.toLowerCase() ?? "https") + : targetData.method; + const plainToken = generateId(48); const encryptedToken = encrypt( plainToken, @@ -230,20 +350,24 @@ export async function createTarget( const existingTargets = await trx .select() .from(targets) - .where(eq(targets.resourceId, resourceId)); + .where( + providerId + ? eq(targets.providerId, providerId) + : eq(targets.resourceId, resourceId!) + ); const existingTarget = existingTargets.find( (target) => target.ip === targetData.ip && target.port === targetData.port && - target.method === targetData.method && + target.method === targetMethod && target.siteId === targetData.siteId ); if (existingTarget) { // log a warning logger.warn( - `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` + `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetMethod} already exists for ${providerId ? `AI provider ID ${providerId}` : `resource ID ${resourceId}`}` ); } @@ -252,10 +376,10 @@ export async function createTarget( .insert(targets) .values({ resourceId, + providerId, ...targetData, - mode: (targetData.mode ?? - resource.mode ?? - "http") as Target["mode"], + mode: targetMode as Target["mode"], + method: targetMethod, priority: targetData.priority || 100 }) .returning(); @@ -289,13 +413,12 @@ export async function createTarget( .insert(targets) .values({ resourceId, + providerId, siteId: site.siteId, ip: targetData.ip, - mode: (targetData.mode ?? - resource.mode ?? - "http") as Target["mode"], + mode: targetMode as Target["mode"], authToken: encryptedToken, - method: targetData.method, + method: targetMethod, port: targetData.port, internalPort, enabled: targetData.enabled, @@ -321,10 +444,12 @@ export async function createTarget( healthCheck = await trx .insert(targetHealthCheck) .values({ - orgId: resource.orgId, + orgId: provider?.orgId ?? resource!.orgId, targetId: newTarget[0].targetId, siteId: targetData.siteId, - name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, + name: provider + ? `AI Provider ${provider.name} - ${targetData.ip}:${targetData.port}` + : `Resource ${resource!.name} - ${targetData.ip}:${targetData.port}`, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, hcScheme: targetData.hcScheme ?? null, @@ -399,10 +524,17 @@ export async function createTarget( newt.newtId, newTarget, healthCheck, - resource.mode === "udp" ? "udp" : "tcp", + provider + ? "tcp" + : (resource!.mode as string) === "udp" + ? "udp" + : "tcp", newt.version ); - } else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) { + } else if ( + !provider && + ["ssh", "rdp", "vnc"].includes(newTarget[0].mode) + ) { await sendBrowserGatewayTargets( newt.newtId, newTarget, diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index a959e10eb..8baf17d66 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -79,38 +79,54 @@ export async function deleteTarget( ) ); } - // get the resource - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, deletedTarget.resourceId!)); - if (!resource) { + if ( + (!deletedTarget.resourceId && !deletedTarget.providerId) || + (deletedTarget.resourceId && deletedTarget.providerId) + ) { return next( createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${deletedTarget.resourceId} not found` + HttpCode.INTERNAL_SERVER_ERROR, + `Target with ID ${targetId} has invalid ownership` ) ); } - // check if there are other targets on the resource - const otherTargets = await db - .select() - .from(targets) - .where( - and( - eq(targets.resourceId, resource.resourceId), - ne(targets.targetId, targetId) - ) - ); + let resource: typeof resources.$inferSelect | undefined; + if (deletedTarget.resourceId) { + [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, deletedTarget.resourceId)) + .limit(1); - if (otherTargets.length == 0) { - // set the resource status - await db - .update(resources) - .set({ health: "unknown" }) - .where(eq(resources.resourceId, resource.resourceId)); + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${deletedTarget.resourceId} not found` + ) + ); + } + + // check if there are other targets on the resource + const otherTargets = await db + .select() + .from(targets) + .where( + and( + eq(targets.resourceId, resource.resourceId), + ne(targets.targetId, targetId) + ) + ); + + if (otherTargets.length == 0) { + // set the resource status + await db + .update(resources) + .set({ health: "unknown" }) + .where(eq(resources.resourceId, resource.resourceId)); + } } const [site] = await db @@ -137,16 +153,26 @@ export async function deleteTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - if (["http", "tcp", "udp"].includes(deletedTarget.mode)) { + if ( + deletedTarget.providerId || + ["http", "tcp", "udp"].includes(deletedTarget.mode) + ) { await removeTargets( newt.newtId, // [deletedTarget], [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this [deletedHealthCheck], - resource.mode === "udp" ? "udp" : "tcp", + deletedTarget.providerId + ? "tcp" + : (resource!.mode as string) === "udp" + ? "udp" + : "tcp", newt.version ); - } else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) { + } else if ( + !deletedTarget.providerId && + ["ssh", "rdp", "vnc"].includes(deletedTarget.mode) + ) { await removeBrowserGatewayTarget( newt.newtId, deletedTarget.targetId, diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index f2d16dd21..fc6058429 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -10,10 +10,19 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -const listTargetsParamsSchema = z.strictObject({ +const resourceTargetsParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); +const providerTargetsParamsSchema = z.strictObject({ + providerId: z.coerce.number().int().positive() +}); + +const listTargetsParamsSchema = z.union([ + resourceTargetsParamsSchema, + providerTargetsParamsSchema +]); + const listTargetsSchema = z.strictObject({ limit: z .string() @@ -29,7 +38,7 @@ const listTargetsSchema = z.strictObject({ .pipe(z.int().nonnegative()) }); -function queryTargets(resourceId: number) { +function queryTargets(owner: { resourceId: number } | { providerId: number }) { const baseQuery = db .select({ targetId: targets.targetId, @@ -39,6 +48,7 @@ function queryTargets(resourceId: number) { port: targets.port, enabled: targets.enabled, resourceId: targets.resourceId, + providerId: targets.providerId, siteId: targets.siteId, siteType: sites.type, siteName: sites.name, @@ -71,7 +81,11 @@ function queryTargets(resourceId: number) { targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) - .where(eq(targets.resourceId, resourceId)); + .where( + "providerId" in owner + ? eq(targets.providerId, owner.providerId) + : eq(targets.resourceId, owner.resourceId) + ); return baseQuery; } @@ -94,7 +108,7 @@ registry.registerPath({ description: "List targets for a resource.", tags: [OpenAPITags.PublicResourceLegacy], request: { - params: listTargetsParamsSchema, + params: resourceTargetsParamsSchema, query: listTargetsSchema }, responses: { @@ -121,7 +135,34 @@ registry.registerPath({ description: "List targets for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { - params: listTargetsParamsSchema, + params: resourceTargetsParamsSchema, + query: listTargetsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +registry.registerPath({ + method: "get", + path: "/ai-provider/{providerId}/targets", + description: "List targets for an AI provider.", + tags: [OpenAPITags.AiProvider], + request: { + params: providerTargetsParamsSchema, query: listTargetsSchema }, responses: { @@ -168,14 +209,18 @@ export async function listTargets( ) ); } - const { resourceId } = parsedParams.data; + const owner = parsedParams.data; + const ownerCondition = + "providerId" in owner + ? eq(targets.providerId, owner.providerId) + : eq(targets.resourceId, owner.resourceId); - const baseQuery = queryTargets(resourceId); + const baseQuery = queryTargets(owner); const countQuery = db .select({ count: sql`cast(count(*) as integer)` }) .from(targets) - .where(eq(targets.resourceId, resourceId)); + .where(ownerCondition); const targetsList = await baseQuery.limit(limit).offset(offset); const totalCountResult = await countQuery; diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 52bf3e578..e1ca95f5f 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, targetHealthCheck } from "@server/db"; -import { newts, resources, sites, targets } from "@server/db"; +import { aiProviders, newts, resources, sites, targets } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -147,21 +147,68 @@ export async function updateTarget( ); } - // get the resource - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, target.resourceId!)); - - if (!resource) { + if ( + (!target.resourceId && !target.providerId) || + (target.resourceId && target.providerId) + ) { return next( createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${target.resourceId} not found` + HttpCode.INTERNAL_SERVER_ERROR, + `Target with ID ${targetId} has invalid ownership` ) ); } + let resource: typeof resources.$inferSelect | undefined; + let provider: typeof aiProviders.$inferSelect | undefined; + + if (target.resourceId) { + [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, target.resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${target.resourceId} not found` + ) + ); + } + } else { + [provider] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, target.providerId!)) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `AI provider with ID ${target.providerId} not found` + ) + ); + } + + if ( + parsedBody.data.method !== undefined && + (!parsedBody.data.method || + !["http", "https"].includes( + parsedBody.data.method.toLowerCase() + )) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "AI provider target method must be http or https" + ) + ); + } + } + const [site] = await db .select() .from(sites) @@ -177,6 +224,15 @@ export async function updateTarget( ); } + if (provider && site.orgId && site.orgId !== provider.orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Site must belong to the AI provider organization" + ) + ); + } + const { internalPort, targetIps } = await pickPort(site.siteId!, db); if (!internalPort) { @@ -221,8 +277,13 @@ export async function updateTarget( } const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; - const nextMode = - parsedBody.data.mode === null ? undefined : parsedBody.data.mode; + const nextMode = provider + ? parsedBody.data.mode !== undefined + ? "http" + : undefined + : parsedBody.data.mode === null + ? undefined + : parsedBody.data.mode; let updatedTarget: any; let updatedHc: any; @@ -233,7 +294,10 @@ export async function updateTarget( siteId: parsedBody.data.siteId, ip: parsedBody.data.ip, mode: nextMode, - method: parsedBody.data.method, + method: + provider && parsedBody.data.method + ? parsedBody.data.method.toLowerCase() + : parsedBody.data.method, port: parsedBody.data.port, internalPort, enabled: parsedBody.data.enabled, @@ -368,15 +432,25 @@ export async function updateTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - if (["http", "tcp", "udp"].includes(updatedTarget.mode)) { + if ( + provider || + ["http", "tcp", "udp"].includes(updatedTarget.mode) + ) { await addTargets( newt.newtId, [updatedTarget], [updatedHc], - resource.mode === "udp" ? "udp" : "tcp", + provider + ? "tcp" + : (resource!.mode as string) === "udp" + ? "udp" + : "tcp", newt.version ); - } else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) { + } else if ( + !provider && + ["ssh", "rdp", "vnc"].includes(updatedTarget.mode) + ) { await sendBrowserGatewayTargets( newt.newtId, [updatedTarget], diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index b8042b2f3..0db4cb156 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -596,6 +596,7 @@ export function ProxyResourceTargetsForm({ priority: 100, enabled: true, resourceId: resource?.resourceId ?? 0, + providerId: null, hcEnabled: false, hcPath: null, hcMethod: null,