Merge upstream/dev into feature-response-headers

Resolve conflicts against upstream's refactors:

- server/db/sqlite/schema/schema.ts: adopt upstream's reindented
  sqliteTable(name, cols, indexes) form for sites/resources, re-applying
  the headers -> requestHeaders/responseHeaders split. Kept in sync with
  the Postgres schema.
- server/lib/traefik/headersMiddleware.ts: extend upstream's extracted
  buildCustomHeadersMiddleware helper to take requestHeaders and
  responseHeaders and emit both customRequestHeaders and
  customResponseHeaders.
- server/lib/traefik/getTraefikConfig.ts and
  server/private/lib/traefik/getTraefikConfig.ts: keep upstream's helper
  extraction and appendPathMatch refactor, dropping the superseded inline
  blocks.

Also carry the feature forward onto code that moved upstream:

- The resource settings UI moved from resources/proxy/[niceId]/proxy to
  resources/public/[niceId]/http, which dropped this branch's changes in
  the previous merge. Re-add the request/response header inputs there and
  rename the vestigial headers field on the tcp page.
- messages/da-DK.json is new upstream and still had the old customHeaders
  key; rename it in line with the other locales.

Per the contributing docs, versioned migrations are intentionally omitted
so maintainers can write them at release time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Julian van der Horst
2026-09-01 13:40:49 +02:00
co-authored by Claude Opus 5
694 changed files with 77422 additions and 12896 deletions
@@ -1,4 +1,3 @@
import { hash } from "@node-rs/argon2";
import {
generateId,
generateIdFromEntropySize,
@@ -8,18 +7,18 @@ import { db } from "@server/db";
import {
ResourceAccessToken,
resourceAccessToken,
resources
resources,
userOrgs
} from "@server/db";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { createDate, TimeSpan } from "oslo";
import { hashPassword } from "@server/auth/password";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { OpenAPITags, registry } from "@server/openApi";
@@ -28,7 +27,9 @@ export const generateAccessTokenBodySchema = z.strictObject({
validForSeconds: z.int().positive().optional(), // seconds
title: z.string().optional(),
path: z.string().optional(),
description: z.string().optional()
description: z.string().optional(),
persistSession: z.boolean().optional().default(false),
userId: z.string().optional()
});
export const generateAccssTokenParamsSchema = z.strictObject({
@@ -44,6 +45,39 @@ registry.registerPath({
method: "post",
path: "/resource/{resourceId}/access-token",
description: "Generate a new access token for a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: generateAccssTokenParamsSchema,
body: {
content: {
"application/json": {
schema: generateAccessTokenBodySchema
}
}
}
},
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: "post",
path: "/public-resource/{resourceId}/access-token",
description: "Generate a new access token for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken],
request: {
params: generateAccssTokenParamsSchema,
@@ -101,7 +135,14 @@ export async function generateAccessToken(
}
const { resourceId } = parsedParams.data;
const { validForSeconds, title, path, description } = parsedBody.data;
const {
validForSeconds,
title,
path,
description,
persistSession,
userId
} = parsedBody.data;
const [resource] = await db
.select()
@@ -112,6 +153,28 @@ export async function generateAccessToken(
return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found"));
}
if (userId) {
const [membership] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, resource.orgId)
)
)
.limit(1);
if (!membership) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User is not a member of this organization"
)
);
}
}
try {
const sessionLength = validForSeconds
? validForSeconds * 1000
@@ -133,23 +196,27 @@ export async function generateAccessToken(
accessTokenId: id,
orgId: resource.orgId,
resourceId,
userId: userId || null,
tokenHash,
expiresAt: expiresAt || null,
sessionLength: sessionLength,
title: title || null,
path: path || null,
description: description || null,
persistSession,
createdAt: new Date().getTime()
})
.returning({
accessTokenId: resourceAccessToken.accessTokenId,
orgId: resourceAccessToken.orgId,
resourceId: resourceAccessToken.resourceId,
userId: resourceAccessToken.userId,
expiresAt: resourceAccessToken.expiresAt,
sessionLength: resourceAccessToken.sessionLength,
title: resourceAccessToken.title,
path: resourceAccessToken.path,
description: resourceAccessToken.description,
persistSession: resourceAccessToken.persistSession,
createdAt: resourceAccessToken.createdAt
})
.execute();
+39 -2
View File
@@ -6,12 +6,13 @@ import {
userResources,
roleResources,
resourceAccessToken,
sites
sites,
users
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm";
import { sql, eq, or, inArray, and, count, isNull, gt } from "drizzle-orm";
import logger from "@server/logger";
import stoi from "@server/lib/stoi";
import { fromZodError } from "zod-validation-error";
@@ -55,11 +56,16 @@ function queryAccessTokens(
accessTokenId: resourceAccessToken.accessTokenId,
orgId: resourceAccessToken.orgId,
resourceId: resourceAccessToken.resourceId,
userId: resourceAccessToken.userId,
userName: users.name,
username: users.username,
userEmail: users.email,
sessionLength: resourceAccessToken.sessionLength,
expiresAt: resourceAccessToken.expiresAt,
tokenHash: resourceAccessToken.tokenHash,
title: resourceAccessToken.title,
description: resourceAccessToken.description,
persistSession: resourceAccessToken.persistSession,
createdAt: resourceAccessToken.createdAt,
resourceName: resources.name,
resourceNiceId: resources.niceId,
@@ -75,6 +81,7 @@ function queryAccessTokens(
eq(resourceAccessToken.resourceId, resources.resourceId)
)
.leftJoin(sites, eq(resources.resourceId, sites.siteId))
.leftJoin(users, eq(resourceAccessToken.userId, users.userId))
.where(
and(
inArray(
@@ -97,6 +104,7 @@ function queryAccessTokens(
eq(resourceAccessToken.resourceId, resources.resourceId)
)
.leftJoin(sites, eq(resources.resourceId, sites.siteId))
.leftJoin(users, eq(resourceAccessToken.userId, users.userId))
.where(
and(
inArray(
@@ -151,6 +159,35 @@ registry.registerPath({
method: "get",
path: "/resource/{resourceId}/access-tokens",
description: "List all access tokens for a resource.",
tags: [OpenAPITags.PublicResourceLegacy],
request: {
params: z.object({
resourceId: z.number()
}),
query: listAccessTokensSchema
},
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: "/public-resource/{resourceId}/access-tokens",
description: "List all access tokens for a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken],
request: {
params: z.object({
+126
View File
@@ -0,0 +1,126 @@
# AI Budget API
Public/OSS CRUD entity (`server/routers/aiBudget/`, not enterprise-gated).
Table: `aiBudgets` in `server/db/{pg,sqlite}/schema/schema.ts`, type `AiBudget`.
## What a budget is
A row is a spend/usage cap of `amount` `unit` (`usd` | `tokens`) per `period`
(`hourly` | `daily` | `weekly` | `monthly` | `yearly` | `lifetime`), with
`enforcement` (`hard` | `soft`) and an `enabled` flag.
Every budget belongs to an org (`orgId`, required) and is optionally further
scoped to **exactly one** of:
- `providerId` → an `aiProviders` row
- `modelId` → an `aiModels` row
- `resourceId` → a `resources` row
- `siteResourceId` → a `siteResources` row
- `roleId` → a `roles` row
If none of those five are set, the budget is **org-wide**. Setting more than
one at once is rejected by `validation.ts`'s `refineBudgetScopeFields`
(`400`, "Only one of providerId, modelId, resourceId, siteResourceId, or
roleId may be set on a budget").
## Uniqueness / conflict rule
A given scope (one specific provider, or model, or resource, or site
resource, or role, or "org-wide") may have **multiple** budgets, but at most
**one per `(unit, period)` combination** — e.g. one `weekly`/`usd` budget and
one `hourly`/`usd` budget can coexist on the same provider, but two
`weekly`/`usd` budgets cannot. This is enforced at two levels:
- DB: composite `unique` constraints in both schema files —
`ai_budget_provider_uniq (providerId, unit, period)`,
`ai_budget_model_uniq (modelId, unit, period)`,
`ai_budget_resource_uniq (resourceId, unit, period)`,
`ai_budget_site_resource_uniq (siteResourceId, unit, period)`,
`ai_budget_role_uniq (roleId, unit, period)`. (NULL scope columns never
collide under a plain unique index, so this does *not* cover the org-wide
case — see next bullet.)
- App: `createAiBudget`/`updateAiBudget` both run an explicit pre-check
query keyed on `(scopeCondition, unit, period)` before insert/update,
where `scopeCondition` is `eq(<scopeColumn>, id)` for whichever scope
field is set, or — when none is set — `orgId = X AND` all five scope
columns `IS NULL`, so org-wide budgets get the same one-per-`(unit,
period)` guarantee even though the DB constraint can't express it.
Violating this returns `409` with
`` `A ${period} ${unit} budget already exists for this scope` ``.
Because only one row can ever exist for a given `(scope, unit, period)`,
there is no separate check needed to prevent a `hard` and a `soft` budget
from coexisting on the same `(scope, unit, period)` — the conflict check
above already blocks the second row regardless of its `enforcement` value.
On `updateAiBudget`, the conflict/ownership checks are run against the
**merged** next-state (existing row's scope/unit/period overlaid with
whatever the request body changes), not just the fields present in the
body — so e.g. changing only `unit` on a budget that already has
`providerId` set re-validates against that provider's other budgets at the
new unit.
## Ownership validation
`providerId`/`modelId`/`resourceId`/`siteResourceId`/`roleId` are validated
to belong to the same `orgId` as the budget (`modelId` via an
`aiModels ⋈ aiProviders` join, since `aiModels` has no `orgId` column
directly). A mismatch returns `404`, not `403` — this matches how the
sibling `aiProvider`/`aiModel` routers report cross-org references.
## Routes
All under `server/routers/external.ts`, registered right after the
`aiProvider`/`aiModel` block. `PUT` = create, `POST` = update (repo
convention, not standard REST).
| Method | Path | Middleware | Action | Handler |
|---|---|---|---|---|
| PUT | `/org/:orgId/ai-budget` | `verifyOrgAccess` | `createAiBudget` | `createAiBudget` |
| GET | `/org/:orgId/ai-budgets` | `verifyOrgAccess` | `listAiBudgets` | `listAiBudgets` (paginated) |
| GET | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `getAiBudget` | `getAiBudget` |
| POST | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `updateAiBudget` | `updateAiBudget` |
| DELETE | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `deleteAiBudget` | `deleteAiBudget` |
| GET | `/ai-provider/:providerId/ai-budgets` | `verifyAiProviderAccess` | `listAiBudgets` | `listAiBudgetsForProvider` |
| GET | `/ai-model/:modelId/ai-budgets` | `verifyAiModelAccess` | `listAiBudgets` | `listAiBudgetsForModel` |
| GET | `/resource/:resourceId/ai-budgets` | `verifyResourceAccess` | `listAiBudgets` | `listAiBudgetsForResource` |
| GET | `/site-resource/:siteResourceId/ai-budgets` | `verifySiteResourceAccess` | `listAiBudgets` | `listAiBudgetsForSiteResource` |
| GET | `/role/:roleId/ai-budgets` | `verifyRoleAccess` | `listAiBudgets` | `listAiBudgetsForRole` |
The five scope-filtered `GET .../ai-budgets` routes intentionally reuse the
single `ActionsEnum.listAiBudgets` action rather than getting one action
each — access control is already fully handled by the entity-specific
middleware (a user who can see the provider/resource/etc. can see its
budgets), so per-scope actions would just be enum bloat. They also skip
pagination (unlike the org-wide list) since a single entity realistically
has only a handful of `(unit, period)` budgets — response shape is a flat
`{ budgets: AiBudget[] }` (`ListAiBudgetsByScopeResponse`), not
`PaginatedResponse`.
`verifyAiBudgetAccess` (`server/middlewares/verifyAiBudgetAccess.ts`) loads
the budget by `budgetId`, resolves its `orgId` directly off the row (no
join needed, unlike `verifyAiModelAccess`), and stashes it on
`req.aiBudget` so `getAiBudget`/`updateAiBudget` can skip a re-fetch.
## Request/response shapes
- Create body: `providerId?`, `modelId?`, `resourceId?`, `siteResourceId?`,
`roleId?` (all `number`, mutually exclusive), `amount` (positive
`number`, required), `unit` (required), `period` (default `"monthly"`),
`enforcement` (default `"hard"`), `enabled?` (default `true`).
- Update body: same fields, all optional; the five scope fields are
`nullable().optional()` so a client can explicitly send `null` to clear
a scope (turning a scoped budget into an org-wide one).
- All five CRUD responses wrap a single `budget: AiBudget` (or
`budgets: AiBudget[]` + `pagination` for the org-wide list). No public/
private mapper exists for `AiBudget` — unlike `AiProvider`, there's no
secret field to strip, so the raw DB row is returned as-is.
## Not yet migrated
Schema changes here (composite unique constraints) were made directly in
`schema.ts` without hand-writing a `server/migrations/*.sql` file — this
repo's CI (`.github/workflows/test.yml`) runs `drizzle-kit generate`
against `schema.ts` fresh, and other recent schema-only commits (e.g. "Remove
budget periods") follow the same pattern of not committing a matching
migration by hand.
+288
View File
@@ -0,0 +1,288 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
aiBudgets,
aiModels,
aiProviders,
db,
resources,
roles,
siteResources,
virtualApiKeys
} 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, isNull } from "drizzle-orm";
import type { CreateOrEditAiBudgetResponse } from "@server/routers/aiBudget/types";
import {
aiBudgetEnforcementSchema,
aiBudgetPeriodSchema,
aiBudgetUnitSchema,
refineBudgetScopeFields
} from "@server/routers/aiBudget/validation";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z
.strictObject({
providerId: z.coerce.number().int().positive().optional(),
modelId: z.coerce.number().int().positive().optional(),
resourceId: z.coerce.number().int().positive().optional(),
siteResourceId: z.coerce.number().int().positive().optional(),
roleId: z.coerce.number().int().positive().optional(),
virtualApiKeyId: z.string().nonempty().optional(),
amount: z.number().positive(),
unit: aiBudgetUnitSchema,
period: aiBudgetPeriodSchema.optional().default("monthly"),
enforcement: aiBudgetEnforcementSchema.optional().default("hard"),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => refineBudgetScopeFields(data, ctx));
registry.registerPath({
method: "put",
path: "/org/{orgId}/ai-budget",
description: "Create an AI budget for an organization.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response"
}
}
});
export async function createAiBudget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const {
providerId,
modelId,
resourceId,
siteResourceId,
roleId,
virtualApiKeyId,
amount,
unit,
period,
enforcement,
enabled
} = parsedBody.data;
if (providerId !== undefined) {
const [provider] = await db
.select({ orgId: aiProviders.orgId })
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider || provider.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found in this organization`
)
);
}
}
if (modelId !== undefined) {
const [model] = await db
.select({ orgId: aiProviders.orgId })
.from(aiModels)
.innerJoin(
aiProviders,
eq(aiModels.providerId, aiProviders.providerId)
)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!model || model.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found in this organization`
)
);
}
}
if (resourceId !== undefined) {
const [resource] = await db
.select({ orgId: resources.orgId })
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource || resource.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in this organization`
)
);
}
}
if (siteResourceId !== undefined) {
const [siteResource] = await db
.select({ orgId: siteResources.orgId })
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!siteResource || siteResource.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site resource with ID ${siteResourceId} not found in this organization`
)
);
}
}
if (roleId !== undefined) {
const [role] = await db
.select({ orgId: roles.orgId })
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
if (!role || role.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Role with ID ${roleId} not found in this organization`
)
);
}
}
if (virtualApiKeyId !== undefined) {
const [key] = await db
.select({ orgId: virtualApiKeys.orgId })
.from(virtualApiKeys)
.where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId))
.limit(1);
if (!key || key.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Virtual API key with ID ${virtualApiKeyId} not found in this organization`
)
);
}
}
const scopeCondition =
providerId !== undefined
? eq(aiBudgets.providerId, providerId)
: modelId !== undefined
? eq(aiBudgets.modelId, modelId)
: resourceId !== undefined
? eq(aiBudgets.resourceId, resourceId)
: siteResourceId !== undefined
? eq(aiBudgets.siteResourceId, siteResourceId)
: roleId !== undefined
? eq(aiBudgets.roleId, roleId)
: virtualApiKeyId !== undefined
? eq(aiBudgets.virtualApiKeyId, virtualApiKeyId)
: and(
eq(aiBudgets.orgId, orgId),
isNull(aiBudgets.providerId),
isNull(aiBudgets.modelId),
isNull(aiBudgets.resourceId),
isNull(aiBudgets.siteResourceId),
isNull(aiBudgets.roleId),
isNull(aiBudgets.virtualApiKeyId)
);
const [existing] = await db
.select({ budgetId: aiBudgets.budgetId })
.from(aiBudgets)
.where(
and(
scopeCondition,
eq(aiBudgets.unit, unit),
eq(aiBudgets.period, period)
)
)
.limit(1);
if (existing) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A ${period} ${unit} budget already exists for this scope`
)
);
}
const now = Date.now();
const [budget] = await db
.insert(aiBudgets)
.values({
orgId,
providerId: providerId ?? null,
modelId: modelId ?? null,
resourceId: resourceId ?? null,
siteResourceId: siteResourceId ?? null,
roleId: roleId ?? null,
virtualApiKeyId: virtualApiKeyId ?? null,
amount,
unit,
period,
enforcement,
enabled: enabled ?? true,
createdAt: now,
updatedAt: now
})
.returning();
return response<CreateOrEditAiBudgetResponse>(res, {
data: { budget },
success: true,
error: false,
message: "AI budget created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiBudgets, 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 { eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
budgetId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "delete",
path: "/ai-budget/{budgetId}",
description: "Delete an AI budget.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function deleteAiBudget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { budgetId } = parsedParams.data;
const [existing] = await db
.select({ budgetId: aiBudgets.budgetId })
.from(aiBudgets)
.where(eq(aiBudgets.budgetId, budgetId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI budget with ID ${budgetId} not found`
)
);
}
await db.delete(aiBudgets).where(eq(aiBudgets.budgetId, budgetId));
return response(res, {
data: null,
success: true,
error: false,
message: "AI budget deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiBudgets, 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 { eq } from "drizzle-orm";
import type { GetAiBudgetResponse } from "@server/routers/aiBudget/types";
const paramsSchema = z.strictObject({
budgetId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/ai-budget/{budgetId}",
description: "Get an AI budget by ID.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getAiBudget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { budgetId } = parsedParams.data;
const [budget] =
req.aiBudget && req.aiBudget.budgetId === budgetId
? [req.aiBudget]
: await db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.budgetId, budgetId))
.limit(1);
if (!budget) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI budget with ID ${budgetId} not found`
)
);
}
return response<GetAiBudgetResponse>(res, {
data: { budget },
success: true,
error: false,
message: "AI budget retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+12
View File
@@ -0,0 +1,12 @@
export * from "./createAiBudget";
export * from "./listAiBudgets";
export * from "./listAiBudgetsForProvider";
export * from "./listAiBudgetsForModel";
export * from "./listAiBudgetsForResource";
export * from "./listAiBudgetsForSiteResource";
export * from "./listAiBudgetsForRole";
export * from "./listAiBudgetsForVirtualApiKey";
export * from "./getAiBudget";
export * from "./updateAiBudget";
export * from "./deleteAiBudget";
export * from "./types";
+130
View File
@@ -0,0 +1,130 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiBudgets, 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 { asc, eq } from "drizzle-orm";
import type { ListAiBudgetsResponse } from "@server/routers/aiBudget/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listSchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
})
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/ai-budgets",
description: "List AI budgets for an organization.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiBudgets(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const { pageSize, page } = parsedQuery.data;
const baseQuery = db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.orgId, orgId));
const countQuery = db.$count(
db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.orgId, orgId))
.as("filtered_ai_budgets")
);
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(aiBudgets.budgetId))
]);
return response<ListAiBudgetsResponse>(res, {
data: {
budgets: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "AI budgets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,69 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiBudgets, 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 { asc, eq } from "drizzle-orm";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/ai-model/{modelId}/ai-budgets",
description: "List AI budgets scoped to an AI model.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiBudgetsForModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const budgets = await db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.modelId, modelId))
.orderBy(asc(aiBudgets.budgetId));
return response<ListAiBudgetsByScopeResponse>(res, {
data: { budgets },
success: true,
error: false,
message: "AI budgets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,69 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiBudgets, 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 { asc, eq } from "drizzle-orm";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}/ai-budgets",
description: "List AI budgets scoped to an AI provider.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiBudgetsForProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const budgets = await db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.providerId, providerId))
.orderBy(asc(aiBudgets.budgetId));
return response<ListAiBudgetsByScopeResponse>(res, {
data: { budgets },
success: true,
error: false,
message: "AI budgets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,69 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiBudgets, 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 { asc, eq } from "drizzle-orm";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
const paramsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/ai-budgets",
description: "List AI budgets scoped to a resource.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiBudgetsForResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const budgets = await db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.resourceId, resourceId))
.orderBy(asc(aiBudgets.budgetId));
return response<ListAiBudgetsByScopeResponse>(res, {
data: { budgets },
success: true,
error: false,
message: "AI budgets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,69 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiBudgets, 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 { asc, eq } from "drizzle-orm";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
const paramsSchema = z.strictObject({
roleId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/role/{roleId}/ai-budgets",
description: "List AI budgets scoped to a role.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiBudgetsForRole(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { roleId } = parsedParams.data;
const budgets = await db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.roleId, roleId))
.orderBy(asc(aiBudgets.budgetId));
return response<ListAiBudgetsByScopeResponse>(res, {
data: { budgets },
success: true,
error: false,
message: "AI budgets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,69 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiBudgets, 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 { asc, eq } from "drizzle-orm";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
const paramsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/site-resource/{siteResourceId}/ai-budgets",
description: "List AI budgets scoped to a site resource.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiBudgetsForSiteResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteResourceId } = parsedParams.data;
const budgets = await db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.siteResourceId, siteResourceId))
.orderBy(asc(aiBudgets.budgetId));
return response<ListAiBudgetsByScopeResponse>(res, {
data: { budgets },
success: true,
error: false,
message: "AI budgets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,69 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiBudgets, 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 { asc, eq } from "drizzle-orm";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
const paramsSchema = z.strictObject({
virtualApiKeyId: z.string().nonempty()
});
registry.registerPath({
method: "get",
path: "/virtual-api-key/{virtualApiKeyId}/ai-budgets",
description: "List AI budgets scoped to a virtual API key.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiBudgetsForVirtualApiKey(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { virtualApiKeyId } = parsedParams.data;
const budgets = await db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.virtualApiKeyId, virtualApiKeyId))
.orderBy(asc(aiBudgets.budgetId));
return response<ListAiBudgetsByScopeResponse>(res, {
data: { budgets },
success: true,
error: false,
message: "AI budgets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { AiBudget } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
export type ListAiBudgetsResponse = PaginatedResponse<{
budgets: AiBudget[];
}>;
export type ListAiBudgetsByScopeResponse = {
budgets: AiBudget[];
};
export type GetAiBudgetResponse = {
budget: AiBudget;
};
export type CreateOrEditAiBudgetResponse = {
budget: AiBudget;
};
+384
View File
@@ -0,0 +1,384 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
aiBudgets,
aiModels,
aiProviders,
db,
resources,
roles,
siteResources,
virtualApiKeys
} 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, isNull, ne } from "drizzle-orm";
import type { CreateOrEditAiBudgetResponse } from "@server/routers/aiBudget/types";
import {
aiBudgetEnforcementSchema,
aiBudgetPeriodSchema,
aiBudgetUnitSchema,
refineBudgetScopeFields
} from "@server/routers/aiBudget/validation";
const paramsSchema = z.strictObject({
budgetId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({
providerId: z.coerce.number().int().positive().nullable().optional(),
modelId: z.coerce.number().int().positive().nullable().optional(),
resourceId: z.coerce.number().int().positive().nullable().optional(),
siteResourceId: z.coerce.number().int().positive().nullable().optional(),
roleId: z.coerce.number().int().positive().nullable().optional(),
virtualApiKeyId: z.string().nonempty().nullable().optional(),
amount: z.number().positive().optional(),
unit: aiBudgetUnitSchema.optional(),
period: aiBudgetPeriodSchema.optional(),
enforcement: aiBudgetEnforcementSchema.optional(),
enabled: z.boolean().optional()
});
registry.registerPath({
method: "post",
path: "/ai-budget/{budgetId}",
description: "Update an AI budget.",
tags: [OpenAPITags.AiBudget],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function updateAiBudget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { budgetId } = parsedParams.data;
const body = parsedBody.data;
const [existing] =
req.aiBudget && req.aiBudget.budgetId === budgetId
? [req.aiBudget]
: await db
.select()
.from(aiBudgets)
.where(eq(aiBudgets.budgetId, budgetId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI budget with ID ${budgetId} not found`
)
);
}
const orgId = existing.orgId;
const nextProviderId =
body.providerId !== undefined
? body.providerId
: existing.providerId;
const nextModelId =
body.modelId !== undefined ? body.modelId : existing.modelId;
const nextResourceId =
body.resourceId !== undefined
? body.resourceId
: existing.resourceId;
const nextSiteResourceId =
body.siteResourceId !== undefined
? body.siteResourceId
: existing.siteResourceId;
const nextRoleId =
body.roleId !== undefined ? body.roleId : existing.roleId;
const nextVirtualApiKeyId =
body.virtualApiKeyId !== undefined
? body.virtualApiKeyId
: existing.virtualApiKeyId;
const nextUnit = body.unit !== undefined ? body.unit : existing.unit;
const nextPeriod =
body.period !== undefined ? body.period : existing.period;
const scopeValidation = z
.object({
providerId: z.number().nullable().optional(),
modelId: z.number().nullable().optional(),
resourceId: z.number().nullable().optional(),
siteResourceId: z.number().nullable().optional(),
roleId: z.number().nullable().optional(),
virtualApiKeyId: z.string().nullable().optional()
})
.superRefine((data, ctx) => refineBudgetScopeFields(data, ctx))
.safeParse({
providerId: nextProviderId,
modelId: nextModelId,
resourceId: nextResourceId,
siteResourceId: nextSiteResourceId,
roleId: nextRoleId,
virtualApiKeyId: nextVirtualApiKeyId
});
if (!scopeValidation.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(scopeValidation.error).toString()
)
);
}
if (body.providerId !== undefined && body.providerId !== null) {
const [provider] = await db
.select({ orgId: aiProviders.orgId })
.from(aiProviders)
.where(eq(aiProviders.providerId, body.providerId))
.limit(1);
if (!provider || provider.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${body.providerId} not found in this organization`
)
);
}
}
if (body.modelId !== undefined && body.modelId !== null) {
const [model] = await db
.select({ orgId: aiProviders.orgId })
.from(aiModels)
.innerJoin(
aiProviders,
eq(aiModels.providerId, aiProviders.providerId)
)
.where(eq(aiModels.modelId, body.modelId))
.limit(1);
if (!model || model.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${body.modelId} not found in this organization`
)
);
}
}
if (body.resourceId !== undefined && body.resourceId !== null) {
const [resource] = await db
.select({ orgId: resources.orgId })
.from(resources)
.where(eq(resources.resourceId, body.resourceId))
.limit(1);
if (!resource || resource.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${body.resourceId} not found in this organization`
)
);
}
}
if (
body.siteResourceId !== undefined &&
body.siteResourceId !== null
) {
const [siteResource] = await db
.select({ orgId: siteResources.orgId })
.from(siteResources)
.where(eq(siteResources.siteResourceId, body.siteResourceId))
.limit(1);
if (!siteResource || siteResource.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site resource with ID ${body.siteResourceId} not found in this organization`
)
);
}
}
if (body.roleId !== undefined && body.roleId !== null) {
const [role] = await db
.select({ orgId: roles.orgId })
.from(roles)
.where(eq(roles.roleId, body.roleId))
.limit(1);
if (!role || role.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Role with ID ${body.roleId} not found in this organization`
)
);
}
}
if (
body.virtualApiKeyId !== undefined &&
body.virtualApiKeyId !== null
) {
const [key] = await db
.select({ orgId: virtualApiKeys.orgId })
.from(virtualApiKeys)
.where(
eq(
virtualApiKeys.virtualApiKeyId,
body.virtualApiKeyId
)
)
.limit(1);
if (!key || key.orgId !== orgId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Virtual API key with ID ${body.virtualApiKeyId} not found in this organization`
)
);
}
}
const scopeCondition =
nextProviderId !== null
? eq(aiBudgets.providerId, nextProviderId)
: nextModelId !== null
? eq(aiBudgets.modelId, nextModelId)
: nextResourceId !== null
? eq(aiBudgets.resourceId, nextResourceId)
: nextSiteResourceId !== null
? eq(aiBudgets.siteResourceId, nextSiteResourceId)
: nextRoleId !== null
? eq(aiBudgets.roleId, nextRoleId)
: nextVirtualApiKeyId !== null
? eq(
aiBudgets.virtualApiKeyId,
nextVirtualApiKeyId
)
: and(
eq(aiBudgets.orgId, orgId),
isNull(aiBudgets.providerId),
isNull(aiBudgets.modelId),
isNull(aiBudgets.resourceId),
isNull(aiBudgets.siteResourceId),
isNull(aiBudgets.roleId),
isNull(aiBudgets.virtualApiKeyId)
);
const [conflict] = await db
.select({ budgetId: aiBudgets.budgetId })
.from(aiBudgets)
.where(
and(
scopeCondition,
eq(aiBudgets.unit, nextUnit),
eq(aiBudgets.period, nextPeriod),
ne(aiBudgets.budgetId, budgetId)
)
)
.limit(1);
if (conflict) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A ${nextPeriod} ${nextUnit} budget already exists for this scope`
)
);
}
const updateData: Partial<typeof aiBudgets.$inferInsert> = {
updatedAt: Date.now()
};
if (body.providerId !== undefined) {
updateData.providerId = body.providerId;
}
if (body.modelId !== undefined) {
updateData.modelId = body.modelId;
}
if (body.resourceId !== undefined) {
updateData.resourceId = body.resourceId;
}
if (body.siteResourceId !== undefined) {
updateData.siteResourceId = body.siteResourceId;
}
if (body.roleId !== undefined) {
updateData.roleId = body.roleId;
}
if (body.virtualApiKeyId !== undefined) {
updateData.virtualApiKeyId = body.virtualApiKeyId;
}
if (body.amount !== undefined) {
updateData.amount = body.amount;
}
if (body.unit !== undefined) {
updateData.unit = body.unit;
}
if (body.period !== undefined) {
updateData.period = body.period;
}
if (body.enforcement !== undefined) {
updateData.enforcement = body.enforcement;
}
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
const [budget] = await db
.update(aiBudgets)
.set(updateData)
.where(eq(aiBudgets.budgetId, budgetId))
.returning();
return response<CreateOrEditAiBudgetResponse>(res, {
data: { budget },
success: true,
error: false,
message: "AI budget updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod";
export const aiBudgetUnitSchema = z.enum(["usd", "tokens"]);
export const aiBudgetPeriodSchema = z.enum([
"monthly",
"yearly",
"lifetime",
"daily",
"hourly",
"weekly"
]);
export const aiBudgetEnforcementSchema = z.enum(["hard", "soft"]);
export function refineBudgetScopeFields(
data: {
providerId?: number | null;
modelId?: number | null;
resourceId?: number | null;
siteResourceId?: number | null;
roleId?: number | null;
virtualApiKeyId?: string | null;
},
ctx: z.RefinementCtx
) {
const scopeFields = [
data.providerId,
data.modelId,
data.resourceId,
data.siteResourceId,
data.roleId,
data.virtualApiKeyId
];
const setCount = scopeFields.filter(
(value) => value !== null && value !== undefined
).length;
if (setCount > 1) {
ctx.addIssue({
code: "custom",
message:
"Only one of providerId, modelId, resourceId, siteResourceId, roleId, or virtualApiKeyId may be set on a budget",
path: ["providerId"]
});
}
}
@@ -0,0 +1,39 @@
import { Router, type Request, type Response } from "express";
import {
AI_CAPABILITY_DEFS,
type AiCapability
} from "@server/lib/aiCapabilities";
import { handleAiGatewayProxy } from "@server/routers/aiGateway/pipeline";
import { handleV1Models } from "@server/routers/aiGateway";
type CapabilityHandler = (
req: Request,
res: Response,
capability: AiCapability
) => Promise<any>;
// Capabilities the gateway answers itself instead of proxying upstream.
// Everything else goes through the inference pipeline.
const LOCAL_HANDLERS: Partial<Record<AiCapability, CapabilityHandler>> = {
v1_models: handleV1Models
};
export function createAiGatewayRouter() {
const router = Router();
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
const capability = def.id as AiCapability;
const handler = LOCAL_HANDLERS[capability] ?? handleAiGatewayProxy;
for (const route of def.routes) {
const bind = (req: Request, res: Response) =>
handler(req, res, capability);
if (route.method === "GET") {
router.get(route.path, bind);
} else {
router.post(route.path, bind);
}
}
}
return router;
}
+3
View File
@@ -0,0 +1,3 @@
export { handleAiGatewayProxy } from "./pipeline";
export { handleV1Models } from "./v1Models";
export { createAiGatewayRouter } from "./createAiGatewayRouter";
+25
View File
@@ -0,0 +1,25 @@
import { AiCapability } from "@app/lib/aiCapabilities";
import { AiProvider } from "@server/db";
/**
* Gracefully flush all pending logs (call this on shutdown)
*/
export async function shutdownAiSessionLogger() {}
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {}
export function logAiSession(data: {
sessionId: string;
capability: AiCapability;
provider: AiProvider;
requestedModel: string | undefined;
requestBody: unknown;
responseText: string;
isStream: boolean;
statusCode: number;
orgId: string | null;
resourceId: number | null;
siteResourceId: number | null;
requestUserId: string | null;
virtualApiKeyId: string | null;
}): void {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
import { Response } from "express";
import { stripInjectedUsageFrame } from "@server/lib/aiUsageExtraction";
/**
* Reads an upstream AI provider response, writes it through to the client
* (streaming or buffered), and returns the full response text once done, so
* the caller can extract usage/cost and log the completed session. Shared by
* both the direct-upstream path (pipeline.ts) and the "custom"/target
* routing-mode path (targetRouting.ts) so usage/cost tracking and session
* logging apply identically to both instead of each maintaining its own copy
* of this loop.
*
* Callers own fetching the upstream response and the AbortController/
* `res.on("close", onClientClose)` wiring, since those differ meaningfully
* between the two transports (direct upstream fetch with TLS-skip support vs
* a plain fetch to gerbil) - only the "read the stream, write to the client,
* accumulate the full text" part is actually identical logic between them.
*/
export async function streamAiGatewayResponse(args: {
res: Response;
upstreamRes: globalThis.Response;
isStream: boolean;
// True when we injected stream_options.include_usage ourselves (the
// caller didn't ask for it) and need to strip the extra usage-only frame
// back out of what's forwarded to the client.
injectedUsageOurselves: boolean;
abortController: AbortController;
onClientClose: () => void;
}): Promise<{ fullText: string; aborted: boolean }> {
const {
res,
upstreamRes,
isStream,
injectedUsageOurselves,
abortController,
onClientClose
} = args;
const contentType = upstreamRes.headers.get("content-type") || "";
res.status(upstreamRes.status);
res.setHeader("Content-Type", contentType || "application/json");
if (isStream && upstreamRes.body) {
res.flushHeaders();
const reader = upstreamRes.body.getReader();
const decoder = new TextDecoder();
let fullText = "";
// Frame-boundary buffer, only used when we need to filter the
// usage-only frame we injected out of what reaches the client.
let sseCarry = "";
try {
while (!abortController.signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
const chunkText = decoder.decode(value, { stream: true });
fullText += chunkText;
if (injectedUsageOurselves) {
sseCarry += chunkText;
const lastBoundary = sseCarry.lastIndexOf("\n\n");
if (lastBoundary !== -1) {
const toEmit = sseCarry.slice(0, lastBoundary + 2);
sseCarry = sseCarry.slice(lastBoundary + 2);
res.write(stripInjectedUsageFrame(toEmit));
}
} else {
res.write(value);
}
}
if (injectedUsageOurselves && sseCarry) {
res.write(stripInjectedUsageFrame(sseCarry));
}
} finally {
await reader.cancel().catch(() => {});
res.off("close", onClientClose);
}
if (!res.writableEnded) {
res.end();
}
return { fullText, aborted: abortController.signal.aborted };
}
res.off("close", onClientClose);
const text = await upstreamRes.text();
res.send(text);
return { fullText: text, aborted: abortController.signal.aborted };
}
+363
View File
@@ -0,0 +1,363 @@
import { Request, Response } from "express";
import { and, eq } from "drizzle-orm";
import {
AiBudget,
AiProvider,
db,
exitNodes,
sites,
targetHealthCheck,
targets
} from "@server/db";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import { localCache } from "@server/lib/cache";
import {
AiProviderAuthType,
applyAiProviderAuthHeaders,
applyAiProviderCustomHeaders,
authTypeRequiresApiKey
} from "@server/lib/aiProviderDefaults";
import {
AI_CAPABILITY_DEFS,
type AiCapability
} from "@server/lib/aiCapabilities";
import { buildAiCapabilityErrorBody } from "@server/lib/aiGatewayAuthError";
import {
needsStreamUsageInjection,
withStreamUsageOption
} from "@server/lib/aiUsageExtraction";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import {
applyRequestUserHeaders,
recordAiGatewayCompletion,
type RequestUser
} from "@server/routers/aiGateway/pipeline";
import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse";
import {
AI_GATEWAY_TRUST_HEADER,
AI_GATEWAY_RESOURCE_TYPE_HEADER
} from "@server/lib/aiGatewayTrust";
// Short TTL: long enough to spare the DB on a burst of requests, short
// enough that target/site changes (added, removed, exit node moved) show up
// almost immediately without needing explicit cache invalidation.
const PROVIDER_TARGETS_TTL_SEC = 7;
// Header gerbil reads to know which scheme://host:port (reachable over the
// WireGuard network) to rewrite an incoming /router/* request to. Must
// match gerbil's `pangolinDestHeader` constant.
const PANGOLIN_DEST_HEADER = "p-dest-header";
// Header gerbil reads for the Host header value to send to the destination,
// when it should differ from PANGOLIN_DEST_HEADER (the target's configured
// ip rather than the WireGuard routing address). Must match gerbil's
// `pangolinHostHeader` constant.
const PANGOLIN_HOST_HEADER = "p-dest-host-header";
const SKIP_HEADERS = new Set([
"p-host",
"host",
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"content-length",
"accept-encoding",
AI_GATEWAY_TRUST_HEADER.toLowerCase(),
AI_GATEWAY_RESOURCE_TYPE_HEADER.toLowerCase()
]);
type ResolvedProviderTarget = {
targetId: number;
// "<scheme>://<site exitNodeSubnet host>:<internalPort>", passed to
// gerbil as the destination to proxy the request to over the WireGuard
// tunnel.
destination: string;
// The target's configured ip, passed to gerbil as the Host header to
// send to the destination (which may differ from the WireGuard routing
// address above, e.g. for vhost-based targets).
hostHeader: string;
// The target's site's exit node HTTP API base URL (gerbil's /router/*).
gerbilBaseUrl: string;
};
async function fetchProviderTargets(
providerId: number
): Promise<ResolvedProviderTarget[]> {
const rows = await db
.select({
targetId: targets.targetId,
ip: targets.ip,
internalPort: targets.internalPort,
port: targets.port,
method: targets.method,
exitNodeSubnet: sites.exitNodeSubnet,
reachableAt: exitNodes.reachableAt,
exitNodeType: exitNodes.type,
hcHealth: targetHealthCheck.hcHealth
})
.from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.innerJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where(
and(eq(targets.providerId, providerId), eq(targets.enabled, true))
);
const resolved: ResolvedProviderTarget[] = [];
for (const row of rows) {
// Sites not yet connected to an exit node (no subnet assigned) or
// whose exit node has no known HTTP address can't be routed to.
if (!row.exitNodeSubnet || !row.reachableAt) {
continue;
}
// Sites connected to a remote exit node aren't reachable via a
// gerbil sidecar's /router/* proxy - only "gerbil" type exit nodes
// run that endpoint.
if (row.exitNodeType !== "gerbil") {
continue;
}
// A target with an active health check that's currently failing is
// taken out of rotation. No health check (null) or "unknown" (check
// hasn't run yet / hcEnabled is off) still routes normally, matching
// the convention in getTraefikConfig.ts's target selection.
if (row.hcHealth === "unhealthy") {
continue;
}
const host = row.exitNodeSubnet.split("/")[0];
const port = row.internalPort ?? row.port;
const scheme = row.method?.toLowerCase() ?? "https";
resolved.push({
targetId: row.targetId,
destination: `${scheme}://${host}:${port}`,
hostHeader: row.ip,
gerbilBaseUrl: row.reachableAt
});
}
return resolved;
}
async function getProviderTargets(
providerId: number
): Promise<ResolvedProviderTarget[]> {
const cacheKey = `aiGateway:providerTargets:${providerId}`;
const cached = localCache.get<ResolvedProviderTarget[]>(cacheKey);
if (cached !== undefined) {
return cached;
}
const resolved = await fetchProviderTargets(providerId);
localCache.set(cacheKey, resolved, PROVIDER_TARGETS_TTL_SEC);
return resolved;
}
// Round-robin cursor per provider. Process-local and unpersisted - fine
// since it only needs to spread load across targets, not guarantee a
// perfectly even distribution across restarts or multiple server instances.
const roundRobinCursors = new Map<number, number>();
function pickTarget(
providerId: number,
providerTargets: ResolvedProviderTarget[]
): ResolvedProviderTarget {
const cursor = roundRobinCursors.get(providerId) ?? 0;
roundRobinCursors.set(providerId, cursor + 1);
return providerTargets[cursor % providerTargets.length];
}
function pathFromRequest(req: Request): string {
// Query string is preserved - some providers use it to select the
// streaming response format (e.g. Gemini's `?alt=sse`), and gerbil's
// /router/* forwards it through untouched.
const raw = req.originalUrl || req.url || req.path;
return raw.startsWith("/") ? raw : `/${raw}`;
}
/**
* Proxies an AI gateway request to one of a "custom" / "target" routing-mode
* provider's site targets, via that site's gerbil sidecar. Gerbil's
* /router/* endpoint forwards the request (untouched body, same path minus
* the /router prefix, and all headers besides PANGOLIN_DEST_HEADER and
* PANGOLIN_HOST_HEADER) over the WireGuard tunnel to the destination named
* in PANGOLIN_DEST_HEADER, sending PANGOLIN_HOST_HEADER as the Host header.
* Always writes a response to `res`, including on failure.
*/
export async function proxyAiGatewayToSiteTarget(
req: Request,
res: Response,
provider: AiProvider,
requestUser: RequestUser | null,
capability: AiCapability,
ctx: {
orgId: string | null;
resourceId: number | null;
siteResourceId: number | null;
requestedModel: string | undefined;
budgets: AiBudget[];
virtualApiKeyId: string | null;
}
): Promise<void> {
const providerTargets = await getProviderTargets(provider.providerId);
if (providerTargets.length === 0) {
res.status(HttpCode.INTERNAL_SERVER_ERROR).json(
buildAiCapabilityErrorBody(
capability,
"internal",
"AI provider has no reachable site targets configured",
HttpCode.INTERNAL_SERVER_ERROR
)
);
return;
}
const target = pickTarget(provider.providerId, providerTargets);
const gerbilUrl = `${target.gerbilBaseUrl.replace(/\/+$/, "")}/router${pathFromRequest(req)}`;
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (SKIP_HEADERS.has(key.toLowerCase()) || value === undefined) {
continue;
}
headers[key] = Array.isArray(value) ? value.join(", ") : value;
}
const authType = provider.authType as AiProviderAuthType;
let apiKey: string | null = null;
if (authTypeRequiresApiKey(authType)) {
if (!provider.apiKey) {
res.status(HttpCode.INTERNAL_SERVER_ERROR).json(
buildAiCapabilityErrorBody(
capability,
"internal",
"AI provider has no API key configured",
HttpCode.INTERNAL_SERVER_ERROR
)
);
return;
}
const secret = config.getRawConfig().server.secret!;
apiKey = decrypt(provider.apiKey, secret);
}
applyAiProviderCustomHeaders(
headers,
provider.headers,
config.getRawConfig().server.secret!
);
applyAiProviderAuthHeaders(headers, authType, apiKey);
applyRequestUserHeaders(headers, requestUser);
headers[PANGOLIN_DEST_HEADER] = target.destination;
headers[PANGOLIN_HOST_HEADER] = target.hostHeader;
// Same OpenAI stream_options.include_usage injection direct-upstream
// requests get (pipeline.ts) - needed here too now that target-routed
// requests get usage/cost tracking and session logging as well.
const injectedUsageOurselves = needsStreamUsageInjection(
capability,
req.body
);
const outboundBody = injectedUsageOurselves
? withStreamUsageOption(req.body)
: req.body;
const body = JSON.stringify(outboundBody);
logger.debug("AI gateway target-routed request", {
providerId: provider.providerId,
targetId: target.targetId,
destination: target.destination,
hostHeader: target.hostHeader,
url: gerbilUrl,
headers,
body: outboundBody
});
// Cancel the request to gerbil (which cascades to gerbil cancelling its
// proxied request to the actual site target, since gerbil's reverse
// proxy derives the outbound request's context from the inbound one) if
// the client goes away before we're done.
const abortController = new AbortController();
const onClientClose = () => {
if (!res.writableEnded) {
abortController.abort();
}
};
res.on("close", onClientClose);
let upstreamRes: globalThis.Response;
try {
upstreamRes = await fetch(gerbilUrl, {
method: "POST",
headers,
body,
signal: abortController.signal
});
} catch (fetchError) {
res.off("close", onClientClose);
if (abortController.signal.aborted) {
// Client already disconnected; nothing left to respond to.
return;
}
logger.error({
message: "AI gateway target proxy request failed",
url: gerbilUrl,
targetId: target.targetId,
error: fetchError,
cause:
fetchError instanceof Error
? (fetchError as Error & { cause?: unknown }).cause
: undefined
});
res.status(HttpCode.BAD_GATEWAY).json(
buildAiCapabilityErrorBody(
capability,
"internal",
"Failed to reach AI provider target",
HttpCode.BAD_GATEWAY
)
);
return;
}
const isStream = AI_CAPABILITY_DEFS[capability].isStreaming(
req,
upstreamRes.headers.get("content-type") || ""
);
const { fullText, aborted } = await streamAiGatewayResponse({
res,
upstreamRes,
isStream,
injectedUsageOurselves,
abortController,
onClientClose
});
if (!aborted) {
recordAiGatewayCompletion({
capability,
provider,
requestedModel: ctx.requestedModel,
requestBody: outboundBody,
responseText: fullText,
isStream,
statusCode: upstreamRes.status,
headers: upstreamRes.headers,
orgId: ctx.orgId,
resourceId: ctx.resourceId,
siteResourceId: ctx.siteResourceId,
requestUserId: requestUser?.userId ?? null,
virtualApiKeyId: ctx.virtualApiKeyId,
budgets: ctx.budgets
});
}
}
+308
View File
@@ -0,0 +1,308 @@
import { Request, Response } from "express";
import { inArray } from "drizzle-orm";
import { z } from "zod";
import { aiModels, db } from "@server/db";
import {
providerHasCapability,
type AiCapability
} from "@server/lib/aiCapabilities";
import {
buildAiCapabilityErrorBody,
type AiCapabilityErrorKind
} from "@server/lib/aiGatewayAuthError";
import {
getAiGatewayResourceType,
isAiGatewayTrustHeaderValid
} from "@server/lib/aiGatewayTrust";
import { resolveEffectiveLists } from "@server/lib/aiInferenceResource";
import { listCatalogEntriesForType } from "@server/lib/aiModelCatalog";
import {
listPermittedModels,
paginateModels,
MODEL_PAGE_DEFAULT_LIMIT,
MODEL_PAGE_MAX_LIMIT,
type CatalogModelMetadata,
type ConfiguredModel,
type ModelDiscoveryProvider
} from "@server/lib/aiModelDiscovery";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import {
resolveGatewayHost,
resolveTarget,
type ProviderAttachment,
type ProviderPatternLists
} from "@server/routers/aiGateway/pipeline";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
const CAPABILITY: AiCapability = "v1_models";
const querySchema = z.object({
limit: z.coerce.number().int().min(1).max(MODEL_PAGE_MAX_LIMIT).optional(),
after_id: z.string().min(1).optional(),
before_id: z.string().min(1).optional()
});
type ProviderModelLists = {
allowsByProvider: Map<number, string[]>;
blocksByProvider: Map<number, string[]>;
configuredByProvider: Map<number, Map<string, ConfiguredModel>>;
};
function errorResponse(
res: Response,
status: number,
kind: AiCapabilityErrorKind,
message: string
) {
return res
.status(status)
.json(buildAiCapabilityErrorBody(CAPABILITY, kind, message, status));
}
// Provider-level allow/block lists, plus the display name and creation time of
// every catalog row, so explicitly configured models are reported with the name
// the administrator gave them rather than a bare model id.
async function loadProviderModelLists(
providerIds: number[]
): Promise<ProviderModelLists> {
const lists: ProviderModelLists = {
allowsByProvider: new Map(),
blocksByProvider: new Map(),
configuredByProvider: new Map()
};
if (providerIds.length === 0) {
return lists;
}
const rows = await db
.select({
providerId: aiModels.providerId,
modelKey: aiModels.modelKey,
name: aiModels.name,
listType: aiModels.listType,
enabled: aiModels.enabled,
createdAt: aiModels.createdAt
})
.from(aiModels)
.where(inArray(aiModels.providerId, providerIds));
for (const row of rows) {
if (!row.enabled) {
continue;
}
const targetMap =
row.listType === "allow"
? lists.allowsByProvider
: lists.blocksByProvider;
const existing = targetMap.get(row.providerId) ?? [];
existing.push(row.modelKey);
targetMap.set(row.providerId, existing);
let configured = lists.configuredByProvider.get(row.providerId);
if (!configured) {
configured = new Map();
lists.configuredByProvider.set(row.providerId, configured);
}
configured.set(row.modelKey, {
name: row.name,
createdAt: row.createdAt
});
}
return lists;
}
function catalogMetadataForType(
type: AiProviderType
): Map<string, CatalogModelMetadata> {
const metadata = new Map<string, CatalogModelMetadata>();
for (const entry of listCatalogEntriesForType(type)) {
metadata.set(entry.model, {
maxInputTokens: entry.limits.input,
maxOutputTokens: entry.limits.output,
capabilities: entry.capabilities
});
}
return metadata;
}
function buildDiscoveryProviders(
attachments: ProviderAttachment[],
resourceListsByProvider: Map<number, ProviderPatternLists>,
lists: ProviderModelLists
): ModelDiscoveryProvider[] {
return attachments.map((attachment) => {
const providerId = attachment.provider.providerId;
const resourceLists = resourceListsByProvider.get(providerId);
const { allows, blocks } = resolveEffectiveLists({
accessMode: attachment.accessMode,
providerAllows: lists.allowsByProvider.get(providerId) ?? [],
providerBlocks: lists.blocksByProvider.get(providerId) ?? [],
resourceAllows: resourceLists?.allows ?? [],
resourceBlocks: resourceLists?.blocks ?? []
});
return {
providerId,
allows,
blocks,
catalog: catalogMetadataForType(
attachment.provider.type as AiProviderType
),
configured: lists.configuredByProvider.get(providerId) ?? new Map()
};
});
}
/**
* Serves Anthropic's model-discovery endpoints (`GET /v1/models` and
* `GET /v1/models/{id}`) for an inference resource. The gateway answers these
* itself rather than proxying: upstream providers either don't expose a model
* list at all or would expose models the resource's allow/block lists forbid,
* so the response is built from the same effective lists that gate inference.
*/
export async function handleV1Models(
req: Request,
res: Response
): Promise<any> {
try {
const host = resolveGatewayHost(req);
if (!host) {
return errorResponse(
res,
HttpCode.BAD_REQUEST,
"invalid_request",
"Missing Host header"
);
}
const resourceType = getAiGatewayResourceType(
req.headers as Record<string, string>
);
const target = await resolveTarget(host, resourceType);
if (!target) {
return errorResponse(
res,
HttpCode.NOT_FOUND,
"not_found",
"No inference resource found for this host"
);
}
// Same gate as the inference pipeline: public inference must pass
// Badger verify-session first, which is what stamps the trust header.
if (
target.resourceId != null &&
!isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)
) {
return errorResponse(
res,
HttpCode.UNAUTHORIZED,
"authentication",
"Request must be authenticated via the inference resource"
);
}
if (target.attachments.length === 0) {
return errorResponse(
res,
HttpCode.FORBIDDEN,
"permission",
"No AI providers configured for this resource"
);
}
const capableAttachments = target.attachments.filter((a) =>
providerHasCapability(a.provider.capabilities, CAPABILITY)
);
if (capableAttachments.length === 0) {
return errorResponse(
res,
HttpCode.FORBIDDEN,
"permission",
`No AI provider on this resource supports ${CAPABILITY}`
);
}
const lists = await loadProviderModelLists(
capableAttachments.map((a) => a.provider.providerId)
);
const models = listPermittedModels(
buildDiscoveryProviders(
capableAttachments,
target.resourceListsByProvider,
lists
)
);
// `GET /v1/models/{id}` - a single model, 404 when this resource
// doesn't permit it.
const requestedModel = req.params?.model;
if (typeof requestedModel === "string" && requestedModel.length > 0) {
const model = models.find((m) => m.id === requestedModel);
if (!model) {
return errorResponse(
res,
HttpCode.NOT_FOUND,
"not_found",
`Model "${requestedModel}" is not available on this resource`
);
}
return res.status(HttpCode.OK).json(model);
}
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return errorResponse(
res,
HttpCode.BAD_REQUEST,
"invalid_request",
parsedQuery.error.issues[0]?.message ??
"Invalid pagination parameters"
);
}
const page = paginateModels(
models,
parsedQuery.data.limit ?? MODEL_PAGE_DEFAULT_LIMIT,
{
afterId: parsedQuery.data.after_id,
beforeId: parsedQuery.data.before_id
}
);
if ("error" in page) {
return errorResponse(
res,
HttpCode.BAD_REQUEST,
"invalid_request",
page.error
);
}
logger.debug("AI gateway model discovery", {
host,
resourceId: target.resourceId,
siteResourceId: target.siteResourceId,
providers: capableAttachments.length,
total: models.length,
returned: page.data.length
});
return res.status(HttpCode.OK).json({
data: page.data,
has_more: page.has_more,
first_id: page.data[0]?.id ?? null,
last_id: page.data[page.data.length - 1]?.id ?? null
});
} catch (error) {
logger.error(error);
return errorResponse(
res,
HttpCode.INTERNAL_SERVER_ERROR,
"internal",
"Failed to list models"
);
}
}
+141
View File
@@ -0,0 +1,141 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, aiProviders, 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 type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import { modelListTypeSchema } from "@server/lib/aiInferenceResource";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({
modelKey: z.string().nonempty(),
name: z.string().nonempty(),
enabled: z.boolean().optional(),
listType: modelListTypeSchema.optional().default("allow")
});
registry.registerPath({
method: "put",
path: "/ai-provider/{providerId}/model",
description: "Create an AI model under a provider.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response"
}
}
});
export async function createAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const { modelKey, name, enabled, listType } = parsedBody.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: 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`
)
);
}
const [existing] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, providerId),
eq(aiModels.modelKey, modelKey)
)
)
.limit(1);
if (existing) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Model with key ${modelKey} already exists for this provider`
)
);
}
const now = Date.now();
const [model] = await db
.insert(aiModels)
.values({
providerId,
modelKey,
name,
listType,
enabled: enabled ?? true,
createdAt: now,
updatedAt: now
})
.returning();
return response<CreateOrEditAiModelResponse>(res, {
data: { model },
success: true,
error: false,
message: "AI model created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,175 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, 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 { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import {
resolveAiProviderCreateFields,
resolveCapabilitiesForCreate,
serializeAiProviderHeaders
} from "@server/lib/aiProviderDefaults";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
import {
aiAuthTypeSchema,
aiCapabilitiesSchema,
aiProviderHeadersSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
import { serializeCapabilities } from "@server/lib/aiCapabilities";
import { getUniqueProviderName, getUniqueResourceName } from "@server/db/names";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z
.strictObject({
name: z.string().nonempty(),
type: aiProviderTypeSchema,
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional(),
routingMode: aiRoutingModeSchema.optional(),
capabilities: aiCapabilitiesSchema.optional(),
headers: aiProviderHeadersSchema,
skipTlsVerification: z.boolean().optional(),
enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
refineProviderUpstreamFields(data, ctx);
});
registry.registerPath({
method: "put",
path: "/org/{orgId}/ai-provider",
description: "Create an AI provider for an organization.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response"
}
}
});
export async function createAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const {
name,
type,
upstreamUrl,
apiKey,
authType,
routingMode,
capabilities,
headers,
skipTlsVerification,
enabled
} = parsedBody.data;
const key = config.getRawConfig().server.secret!;
const encryptedApiKey = apiKey ? encrypt(apiKey, key) : null;
const apiKeyLastChars = apiKey ? apiKey.slice(-4) : null;
const now = Date.now();
const resolved = resolveAiProviderCreateFields({
type,
upstreamUrl,
authType,
routingMode
});
const resolvedCapabilities = resolveCapabilitiesForCreate({
type,
capabilities
});
if (resolvedCapabilities.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one capability is required"
)
);
}
const niceId = await getUniqueProviderName(orgId);
const [provider] = await db
.insert(aiProviders)
.values({
orgId,
name,
niceId,
type,
upstreamUrl: resolved.upstreamUrl,
apiKey: encryptedApiKey,
apiKeyLastChars,
authType: resolved.authType,
routingMode: resolved.routingMode,
capabilities: serializeCapabilities(resolvedCapabilities),
headers: serializeAiProviderHeaders(headers, key),
skipTlsVerification: skipTlsVerification ?? false,
enabled: enabled ?? true,
createdAt: now,
updatedAt: now
})
.returning();
return response<CreateOrEditAiProviderResponse>(res, {
data: {
provider: toPublicAiProvider(provider, { includeApiKey: true })
},
success: true,
error: false,
message: "AI provider created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,79 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 { eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "delete",
path: "/ai-model/{modelId}",
description: "Delete an AI model.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function deleteAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const [existing] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
await db.delete(aiModels).where(eq(aiModels.modelId, modelId));
return response(res, {
data: null,
success: true,
error: false,
message: "AI model deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, 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 { eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "delete",
path: "/ai-provider/{providerId}",
description: "Delete an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function deleteAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const [existing] = await db
.select({ providerId: aiProviders.providerId })
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
await db
.delete(aiProviders)
.where(eq(aiProviders.providerId, providerId));
return response(res, {
data: null,
success: true,
error: false,
message: "AI provider deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 { eq } from "drizzle-orm";
import type { GetAiModelResponse } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "get",
path: "/ai-model/{modelId}",
description: "Get an AI model by ID.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const [model] =
req.aiModel && req.aiModel.modelId === modelId
? [req.aiModel]
: await db
.select()
.from(aiModels)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!model) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
return response<GetAiModelResponse>(res, {
data: { model },
success: true,
error: false,
message: "AI model retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+136
View File
@@ -0,0 +1,136 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, db } from "@server/db";
import response from "@server/lib/response";
import stoi from "@server/lib/stoi";
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 type { GetAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
providerId: z
.string()
.optional()
.transform(stoi)
.pipe(z.int().positive().optional())
.optional(),
niceId: z.string().optional(),
orgId: z.string().optional()
});
async function query(providerId?: number, niceId?: string, orgId?: string) {
if (providerId) {
const [res] = await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
return res;
} else if (niceId && orgId) {
const [res] = await db
.select()
.from(aiProviders)
.where(
and(
eq(aiProviders.niceId, niceId),
eq(aiProviders.orgId, orgId)
)
)
.limit(1);
return res;
}
}
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}",
description: "Get an AI provider by ID.",
tags: [OpenAPITags.AiProvider],
request: {
params: z.object({
providerId: z.string()
})
},
responses: {
200: {
description: "Successful response"
}
}
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/ai-provider/{niceId}",
description:
"Get an AI provider by orgId and niceId. NiceId is a readable ID for the provider and unique on a per org basis.",
tags: [OpenAPITags.AiProvider],
request: {
params: z.object({
orgId: z.string(),
niceId: z.string()
})
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId, niceId, orgId } = parsedParams.data;
const provider =
req.aiProvider &&
(req.aiProvider.providerId === providerId ||
(niceId &&
req.aiProvider.niceId === niceId &&
req.aiProvider.orgId === orgId))
? req.aiProvider
: await query(providerId, niceId, orgId);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId || niceId} not found`
)
);
}
return response<GetAiProviderResponse>(res, {
data: {
provider: toPublicAiProvider(provider, { includeApiKey: true })
},
success: true,
error: false,
message: "AI provider retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+13
View File
@@ -0,0 +1,13 @@
export * from "./createAiProvider";
export * from "./listAiProviders";
export * from "./getAiProvider";
export * from "./updateAiProvider";
export * from "./deleteAiProvider";
export * from "./createAiModel";
export * from "./listAiModels";
export * from "./listCatalogModels";
export * from "./listCatalogModelsByType";
export * from "./getAiModel";
export * from "./updateAiModel";
export * from "./deleteAiModel";
export * from "./types";
+160
View File
@@ -0,0 +1,160 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiModels, aiProviders, 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, asc, eq, like, sql } from "drizzle-orm";
import type { ListAiModelsResponse } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const listSchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}/models",
description: "List AI models for a provider.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select({ providerId: aiProviders.providerId })
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [eq(aiModels.providerId, providerId)];
if (query) {
conditions.push(
like(
sql`LOWER(${aiModels.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = db
.select()
.from(aiModels)
.where(and(...conditions));
const countQuery = db.$count(
db
.select()
.from(aiModels)
.where(and(...conditions))
.as("filtered_ai_models")
);
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(aiModels.name))
]);
return response<ListAiModelsResponse>(res, {
data: {
models: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "AI models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,152 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, 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, asc, eq, like, sql } from "drizzle-orm";
import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listSchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/ai-providers",
description: "List AI providers for an organization.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listAiProviders(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [eq(aiProviders.orgId, orgId)];
if (query) {
conditions.push(
like(
sql`LOWER(${aiProviders.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = db
.select()
.from(aiProviders)
.where(and(...conditions));
const countQuery = db.$count(
db
.select()
.from(aiProviders)
.where(and(...conditions))
.as("filtered_ai_providers")
);
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(aiProviders.name))
]);
return response<ListAiProvidersResponse>(res, {
data: {
providers: rows.map((row) => toPublicAiProvider(row)),
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "AI providers retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,104 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, 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 { eq } from "drizzle-orm";
import { listCatalogModelsForType } from "@server/lib/aiModelCatalog";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type { ListCatalogModelsResponse } from "@server/routers/aiProvider/types";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const listSchema = z.object({
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/ai-provider/{providerId}/catalog-models",
description:
"List known catalog models for an AI provider's type. Used for model key suggestions.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listCatalogModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const [provider] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: 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`
)
);
}
const models = listCatalogModelsForType(
provider.type as AiProviderType,
parsedQuery.data.query
);
return response<ListCatalogModelsResponse>(res, {
data: { models },
success: true,
error: false,
message: "Catalog models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 { listCatalogModelsForType } from "@server/lib/aiModelCatalog";
import type { ListCatalogModelsResponse } from "@server/routers/aiProvider/types";
import { aiProviderTypeSchema } from "@server/routers/aiProvider/validation";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const querySchema = z.strictObject({
type: aiProviderTypeSchema,
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/ai-catalog-models",
description:
"List known catalog models for an AI provider type. Used for model key suggestions before a provider exists.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
query: querySchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listCatalogModelsByType(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { type, query } = parsedQuery.data;
const models = listCatalogModelsForType(type, query);
return response<ListCatalogModelsResponse>(res, {
data: { models },
success: true,
error: false,
message: "Catalog models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { AiModel, AiProvider } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
import {
parseAiProviderHeaders,
type AiProviderAuthType,
type AiProviderHeader
} from "@server/lib/aiProviderDefaults";
import {
parseCapabilities,
type AiCapability
} from "@server/lib/aiCapabilities";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
export type AiProviderPublic = Omit<
AiProvider,
"apiKey" | "capabilities" | "headers"
> & {
apiKey?: string | null;
capabilities: AiCapability[];
headers: AiProviderHeader[] | null;
effectiveUpstreamUrl: string | null;
effectiveAuthType: AiProviderAuthType;
};
export type ListAiProvidersResponse = PaginatedResponse<{
providers: AiProviderPublic[];
}>;
export type GetAiProviderResponse = {
provider: AiProviderPublic;
};
export type CreateOrEditAiProviderResponse = {
provider: AiProviderPublic;
};
export type ListAiModelsResponse = PaginatedResponse<{
models: AiModel[];
}>;
export type ListCatalogModelsResponse = {
models: { model: string }[];
};
export type GetAiModelResponse = {
model: AiModel;
};
export type CreateOrEditAiModelResponse = {
model: AiModel;
};
export function toPublicAiProvider(
provider: AiProvider,
options?: { includeApiKey?: boolean }
): AiProviderPublic {
const {
apiKey: encryptedApiKey,
capabilities: rawCapabilities,
headers: rawHeaders,
...rest
} = provider;
let apiKey: string | null | undefined;
if (options?.includeApiKey) {
if (encryptedApiKey) {
apiKey = decrypt(
encryptedApiKey,
config.getRawConfig().server.secret!
);
} else {
apiKey = null;
}
}
const parsedHeaders = parseAiProviderHeaders(
rawHeaders,
config.getRawConfig().server.secret!
);
return {
...rest,
...(options?.includeApiKey ? { apiKey } : {}),
capabilities: parseCapabilities(rawCapabilities),
headers: parsedHeaders.length > 0 ? parsedHeaders : null,
effectiveUpstreamUrl: provider.upstreamUrl,
effectiveAuthType: provider.authType as AiProviderAuthType
};
}
+156
View File
@@ -0,0 +1,156 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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, ne } from "drizzle-orm";
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import { modelListTypeSchema } from "@server/lib/aiInferenceResource";
const paramsSchema = z.strictObject({
modelId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({
modelKey: z.string().nonempty().optional(),
name: z.string().nonempty().optional(),
enabled: z.boolean().optional(),
listType: modelListTypeSchema.optional()
});
registry.registerPath({
method: "post",
path: "/ai-model/{modelId}",
description: "Update an AI model.",
tags: [OpenAPITags.AiModel],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function updateAiModel(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { modelId } = parsedParams.data;
const body = parsedBody.data;
const [existing] =
req.aiModel && req.aiModel.modelId === modelId
? [req.aiModel]
: await db
.select()
.from(aiModels)
.where(eq(aiModels.modelId, modelId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI model with ID ${modelId} not found`
)
);
}
if (
body.modelKey !== undefined &&
body.modelKey !== existing.modelKey
) {
const [conflict] = await db
.select({ modelId: aiModels.modelId })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, existing.providerId),
eq(aiModels.modelKey, body.modelKey),
ne(aiModels.modelId, modelId)
)
)
.limit(1);
if (conflict) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Model with key ${body.modelKey} already exists for this provider`
)
);
}
}
const updateData: Partial<typeof aiModels.$inferInsert> = {
updatedAt: Date.now()
};
if (body.modelKey !== undefined) {
updateData.modelKey = body.modelKey;
}
if (body.name !== undefined) {
updateData.name = body.name;
}
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
if (body.listType !== undefined) {
updateData.listType = body.listType;
}
const [model] = await db
.update(aiModels)
.set(updateData)
.where(eq(aiModels.modelId, modelId))
.returning();
return response<CreateOrEditAiModelResponse>(res, {
data: { model },
success: true,
error: false,
message: "AI model updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,258 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { aiProviders, 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 { eq, ne, and } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { toPublicAiProvider } from "@server/routers/aiProvider/types";
import {
aiAuthTypeSchema,
aiCapabilitiesSchema,
aiProviderHeadersSchema,
aiProviderTypeSchema,
aiRoutingModeSchema,
refineProviderUpstreamFields
} from "@server/routers/aiProvider/validation";
import {
serializeAiProviderHeaders,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
import {
parseCapabilities,
serializeCapabilities
} from "@server/lib/aiCapabilities";
const paramsSchema = z.strictObject({
providerId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({
name: z.string().nonempty().optional(),
niceId: z
.string()
.min(1)
.max(255)
.regex(
/^[a-zA-Z0-9-]+$/,
"niceId can only contain letters, numbers, and dashes"
)
.optional(),
upstreamUrl: z.url().optional().nullable(),
apiKey: z.string().optional(),
authType: aiAuthTypeSchema.optional(),
routingMode: aiRoutingModeSchema.optional(),
capabilities: aiCapabilitiesSchema.optional(),
headers: aiProviderHeadersSchema,
skipTlsVerification: z.boolean().optional(),
enabled: z.boolean().optional()
});
registry.registerPath({
method: "post",
path: "/ai-provider/{providerId}",
description: "Update an AI provider.",
tags: [OpenAPITags.AiProvider],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function updateAiProvider(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { providerId } = parsedParams.data;
const body = parsedBody.data;
const [existing] =
req.aiProvider && req.aiProvider.providerId === providerId
? [req.aiProvider]
: await db
.select()
.from(aiProviders)
.where(eq(aiProviders.providerId, providerId))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`AI provider with ID ${providerId} not found`
)
);
}
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
: existing.upstreamUrl;
const nextAuthType: AiProviderAuthType =
body.authType !== undefined
? body.authType
: (existing.authType as AiProviderAuthType);
const nextCapabilities =
body.capabilities !== undefined
? body.capabilities
: parseCapabilities(existing.capabilities);
const validation = z
.object({
type: aiProviderTypeSchema,
upstreamUrl: z.string().nullable().optional(),
authType: aiAuthTypeSchema,
routingMode: aiRoutingModeSchema.optional(),
capabilities: aiCapabilitiesSchema.optional()
})
.superRefine((data, ctx) => refineProviderUpstreamFields(data, ctx))
.safeParse({
type: providerType,
upstreamUrl: nextUpstreamUrl,
authType: nextAuthType,
routingMode: nextRoutingMode,
capabilities: nextCapabilities
});
if (!validation.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(validation.error).toString()
)
);
}
const updateData: Partial<typeof aiProviders.$inferInsert> = {
updatedAt: Date.now(),
routingMode: nextRoutingMode
};
if (body.name !== undefined) {
updateData.name = body.name;
}
if (body.niceId !== undefined) {
updateData.niceId = body.niceId;
}
if (body.skipTlsVerification !== undefined) {
updateData.skipTlsVerification = body.skipTlsVerification;
}
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
if (nextRoutingMode === "target") {
updateData.upstreamUrl = null;
} else if (body.upstreamUrl !== undefined) {
updateData.upstreamUrl = body.upstreamUrl;
}
if (body.authType !== undefined) {
updateData.authType = body.authType;
}
if (body.capabilities !== undefined) {
updateData.capabilities = serializeCapabilities(body.capabilities);
}
if (body.apiKey !== undefined) {
const key = config.getRawConfig().server.secret!;
updateData.apiKey = encrypt(body.apiKey, key);
updateData.apiKeyLastChars = body.apiKey.slice(-4);
}
if (body.headers !== undefined) {
const key = config.getRawConfig().server.secret!;
updateData.headers = serializeAiProviderHeaders(body.headers, key);
}
if (updateData.niceId) {
const [existingAiProvider] = await db
.select()
.from(aiProviders)
.where(
and(
eq(aiProviders.niceId, updateData.niceId),
eq(aiProviders.orgId, existing.orgId),
ne(aiProviders.providerId, existing.providerId) // exclude the current provider from the search
)
)
.limit(1);
if (existingAiProvider) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A resource with niceId "${updateData.niceId}" already exists`
)
);
}
}
const [provider] = await db
.update(aiProviders)
.set(updateData)
.where(eq(aiProviders.providerId, providerId))
.returning();
return response<CreateOrEditAiProviderResponse>(res, {
data: {
provider: toPublicAiProvider(provider, { includeApiKey: true })
},
success: true,
error: false,
message: "AI provider updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import { z } from "zod";
import {
AI_PROVIDER_AUTH_TYPES,
providerRequiresUpstreamUrl,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@server/lib/aiProviderDefaults";
import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
export const aiProviderTypeSchema = z.enum([
"openai",
"anthropic",
"googleGemini",
"vertexAi",
"bedrock",
"microsoftFoundry",
"openRouter",
"vercelAiGateway",
"custom"
]);
export const aiAuthTypeSchema = z.enum(AI_PROVIDER_AUTH_TYPES);
export const aiRoutingModeSchema = z.enum(["url", "target"]);
export const aiCapabilitySchema = z.enum(AI_CAPABILITIES);
export const aiCapabilitiesSchema = z.array(aiCapabilitySchema);
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const validHeaderValue = /^[\t\x20-\x7E]*$/;
const templatePattern = /\{\{[^}]+\}\}/;
export const aiProviderHeadersSchema = z
.array(z.strictObject({ name: z.string(), value: z.string() }))
.nullable()
.optional()
.superRefine((headers, ctx) => {
if (!headers) {
return;
}
for (const [index, header] of headers.entries()) {
if (!validHeaderName.test(header.name)) {
ctx.addIssue({
code: "custom",
message:
"Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~).",
path: [index, "name"]
});
}
if (!validHeaderValue.test(header.value)) {
ctx.addIssue({
code: "custom",
message:
"Header values may only contain printable ASCII characters and horizontal whitespace.",
path: [index, "value"]
});
}
if (
templatePattern.test(header.name) ||
templatePattern.test(header.value)
) {
ctx.addIssue({
code: "custom",
message:
"Header names and values must not contain template expressions such as {{value}}.",
path: [index]
});
}
}
});
export function refineProviderUpstreamFields(
data: {
type: AiProviderType;
upstreamUrl?: string | null;
authType?: AiProviderAuthType | null;
routingMode?: AiProviderRoutingMode | null;
capabilities?: z.infer<typeof aiCapabilitiesSchema> | null;
},
ctx: z.RefinementCtx
) {
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`,
path: ["upstreamUrl"]
});
}
if (data.type === "custom") {
const caps = data.capabilities;
if (!caps || caps.length === 0) {
ctx.addIssue({
code: "custom",
message:
"At least one capability is required for custom providers",
path: ["capabilities"]
});
}
} else if (
data.capabilities !== undefined &&
data.capabilities !== null &&
data.capabilities.length === 0
) {
ctx.addIssue({
code: "custom",
message: "At least one capability is required",
path: ["capabilities"]
});
}
}
+23
View File
@@ -124,3 +124,26 @@ export interface AlertContext {
/** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>;
}
export type EmailAlertAction = {
type: "email";
userIds?: string[];
roleIds?: number[];
emails?: string[];
};
export type WebhookAlertAction = {
type: "webhook";
webhookUrl: string;
enabled: boolean;
config?: string | undefined;
};
export type AlertAction = EmailAlertAction | WebhookAlertAction;
export interface TestAlertContext {
eventType: AlertEventType;
actions: AlertAction[];
orgId: string;
/** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>;
}
@@ -0,0 +1,174 @@
import { and, eq, gte, lte, or, inArray, sql } from "drizzle-orm";
import { aiUsageRecords, userOrgRoles, driver, db } from "@server/db";
import { z } from "zod";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
// Cap on how many distinct series a trend chart will plot before collapsing
// the remainder into an "other" bucket - matches the theme's 5 categorical
// chart colors (--chart-1..--chart-5).
export const TOP_N = 5;
// Same guard used by queryRequestAnalytics/queryAiSessionLog for distinct
// breakdown lists.
export const DISTINCT_LIMIT = 500;
export const aiUsageAnalyticsFiltersQuery = z.object({
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"Start time as ISO date string (defaults to 7 days ago)"
}),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => new Date().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"End time as ISO date string (defaults to current time)"
}),
providerId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional(),
model: z.string().optional(),
resourceId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional(),
roleId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional(),
userId: z.string().optional(),
virtualApiKeyId: z.string().optional()
});
export const aiUsageAnalyticsParams = z.object({
orgId: z.string()
});
export const aiUsageAnalyticsCombined = aiUsageAnalyticsFiltersQuery.merge(
aiUsageAnalyticsParams
);
export type AiUsageAnalyticsQuery = z.infer<typeof aiUsageAnalyticsCombined>;
// A role has no column on aiUsageRecords - it's derived by resolving the
// role's members to userIds first, then filtering on userId. If the role has
// no members we still need the filter to exclude everything rather than be
// silently ignored, hence the sentinel value.
export async function resolveRoleUserIds(
orgId: string,
roleId?: number
): Promise<string[] | undefined> {
if (!roleId) {
return undefined;
}
const rows = await db
.select({ userId: userOrgRoles.userId })
.from(userOrgRoles)
.where(
and(eq(userOrgRoles.orgId, orgId), eq(userOrgRoles.roleId, roleId))
);
return rows.length > 0
? rows.map((r) => r.userId)
: ["__no_users_in_role__"];
}
export function buildAiUsageWhere(
data: AiUsageAnalyticsQuery,
roleUserIds?: string[]
) {
return and(
eq(aiUsageRecords.orgId, data.orgId),
gte(aiUsageRecords.createdAt, data.timeStart),
lte(aiUsageRecords.createdAt, data.timeEnd),
data.providerId
? eq(aiUsageRecords.providerId, data.providerId)
: undefined,
data.model ? eq(aiUsageRecords.requestedModel, data.model) : undefined,
data.resourceId
? or(
eq(aiUsageRecords.resourceId, data.resourceId),
eq(aiUsageRecords.siteResourceId, data.resourceId)
)
: undefined,
data.userId ? eq(aiUsageRecords.userId, data.userId) : undefined,
data.virtualApiKeyId
? eq(aiUsageRecords.virtualApiKeyId, data.virtualApiKeyId)
: undefined,
roleUserIds ? inArray(aiUsageRecords.userId, roleUserIds) : undefined
);
}
// Buckets createdAt (epoch seconds) down to a per-day string, dialect-aware,
// same approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts.
export function dayBucketExpr() {
return driver === "pg"
? sql<string>`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt}))`
: sql<string>`DATE(${aiUsageRecords.createdAt}, 'unixepoch')`;
}
export type DailyMetricRow<K extends string> = {
day: string;
key: K;
value: number;
};
// Ranks dimension keys by total value and returns the top N.
export function pickTopNKeys<K extends string>(
totals: Map<K, number>,
n: number = TOP_N
): K[] {
return [...totals.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, n)
.map(([key]) => key);
}
export interface DayValueRow {
day: string;
[seriesKey: string]: number | string;
}
// Collapses per-day, per-key rows into a per-day series object, folding
// anything outside `topKeys` into a shared "other" series.
export function bucketTopNPerDay<K extends string>(
rows: DailyMetricRow<K>[],
topKeys: K[]
): DayValueRow[] {
const topSet = new Set<string>(topKeys);
const byDay = new Map<string, Record<string, number>>();
for (const row of rows) {
const seriesKey = topSet.has(row.key) ? row.key : "other";
const dayEntry = byDay.get(row.day) ?? {};
dayEntry[seriesKey] = (dayEntry[seriesKey] ?? 0) + row.value;
byDay.set(row.day, dayEntry);
}
return [...byDay.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, values]) => ({ day, ...values }));
}
@@ -0,0 +1,110 @@
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { OpenAPITags } from "@server/openApi";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { z } from "zod";
import logger from "@server/logger";
import {
queryAiSessionLogsQuery,
queryAiSessionLogsParams,
queryAiSession,
countAiSessionQuery,
decompressAiSessionLogRow
} from "./queryAiSessionLog";
import { generateCSV } from "./generateCSV";
const MAX_EXPORT_LIMIT = 50_000;
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/ai/export",
description: "Export the AI gateway session log for an organization as CSV",
tags: [OpenAPITags.Logs],
request: {
query: queryAiSessionLogsQuery.omit({
limit: true,
offset: true
}),
params: queryAiSessionLogsParams
},
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()
})
}
}
}
}
});
export async function exportAiSessionLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAiSessionLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryAiSessionLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = { ...parsedQuery.data, ...parsedParams.data };
const [{ count }] = await countAiSessionQuery(data);
if (count > MAX_EXPORT_LIMIT) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.`
)
);
}
const baseQuery = queryAiSession(data);
const log = (await baseQuery.limit(MAX_EXPORT_LIMIT)).map(
decompressAiSessionLogRow
);
const csvData = generateCSV(log);
res.setHeader("Content-Type", "text/csv");
res.setHeader(
"Content-Disposition",
`attachment; filename="ai-session-logs-${data.orgId}-${Date.now()}.csv"`
);
return res.send(csvData);
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+8
View File
@@ -1,3 +1,11 @@
export * from "./queryRequestAuditLog";
export * from "./queryRequestAnalytics";
export * from "./exportRequestAuditLog";
export * from "./queryAiSessionLog";
export * from "./exportAiSessionLog";
export * from "./queryAiUsageFilterOptions";
export * from "./queryAiUsageOverview";
export * from "./queryAiUsageProviders";
export * from "./queryAiUsageResources";
export * from "./queryAiUsageUsersRoles";
export * from "./queryAiUsageVirtualApiKeys";
@@ -0,0 +1,689 @@
import {
logsDb,
aiSessionLog,
aiProviders,
aiUsageRecords,
resources,
siteResources,
users,
virtualApiKeys,
db,
primaryDb
} from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { QueryAiSessionLogResponse } from "@server/routers/auditLogs/types";
import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
import response from "@server/lib/response";
import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { decompressText } from "@server/lib/textCompression";
export const queryAiSessionLogsQuery = z.strictObject({
// iso string just validate its a parseable date
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"Start time as ISO date string (defaults to 7 days ago)"
}),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => new Date().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"End time as ISO date string (defaults to current time)"
}),
providerId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional(),
capability: z.enum(AI_CAPABILITIES).optional(),
resourceId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional(),
actor: z.string().optional(),
virtualApiKeyId: z.string().optional(),
model: z.string().optional(),
isStream: z
.union([z.boolean(), z.string()])
.transform((val) => (typeof val === "string" ? val === "true" : val))
.optional(),
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
});
export const queryAiSessionLogsParams = z.object({
orgId: z.string()
});
export const queryAiSessionLogsCombined = queryAiSessionLogsQuery.merge(
queryAiSessionLogsParams
);
type Q = z.infer<typeof queryAiSessionLogsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
items: T[]
): T[] {
return [...items].sort((a, b) => {
const nameA = a.name ?? "";
const nameB = b.name ?? "";
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return a.id - b.id;
});
}
function getWhere(data: Q) {
return and(
gt(aiSessionLog.createdAt, data.timeStart),
lt(aiSessionLog.createdAt, data.timeEnd),
eq(aiSessionLog.orgId, data.orgId),
data.providerId
? eq(aiSessionLog.providerId, data.providerId)
: undefined,
data.capability
? eq(aiSessionLog.capability, data.capability)
: undefined,
data.resourceId
? or(
eq(aiSessionLog.resourceId, data.resourceId),
eq(aiSessionLog.siteResourceId, data.resourceId)
)
: undefined,
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
data.virtualApiKeyId
? eq(aiSessionLog.virtualApiKeyId, data.virtualApiKeyId)
: undefined,
data.model ? eq(aiSessionLog.requestedModel, data.model) : undefined,
data.isStream !== undefined
? eq(aiSessionLog.isStream, data.isStream)
: undefined
);
}
export function queryAiSession(data: Q) {
return logsDb
.select({
id: aiSessionLog.id,
sessionId: aiSessionLog.sessionId,
orgId: aiSessionLog.orgId,
providerId: aiSessionLog.providerId,
capability: aiSessionLog.capability,
resourceId: aiSessionLog.resourceId,
siteResourceId: aiSessionLog.siteResourceId,
userId: aiSessionLog.userId,
virtualApiKeyId: aiSessionLog.virtualApiKeyId,
requestedModel: aiSessionLog.requestedModel,
isStream: aiSessionLog.isStream,
requestBody: aiSessionLog.requestBody,
responseBody: aiSessionLog.responseBody,
normalizedRequest: aiSessionLog.normalizedRequest,
normalizedResponse: aiSessionLog.normalizedResponse,
truncated: aiSessionLog.truncated,
statusCode: aiSessionLog.statusCode,
createdAt: aiSessionLog.createdAt
})
.from(aiSessionLog)
.where(getWhere(data))
.orderBy(desc(aiSessionLog.createdAt));
}
function decompressField(value: string | null): string | null {
if (value == null) {
return value;
}
try {
return decompressText(value);
} catch (error) {
logger.error("Failed to decompress AI session log field", { error });
return value;
}
}
export function decompressAiSessionLogRow<
T extends {
requestBody: string | null;
responseBody: string | null;
normalizedRequest: string | null;
normalizedResponse: string | null;
}
>(row: T): T {
return {
...row,
requestBody: decompressField(row.requestBody),
responseBody: decompressField(row.responseBody),
normalizedRequest: decompressField(row.normalizedRequest),
normalizedResponse: decompressField(row.normalizedResponse)
};
}
async function enrichWithDetails(
logs: Awaited<ReturnType<typeof queryAiSession>>
) {
const providerIds = [...new Set(logs.map((log) => log.providerId))];
const resourceIds = logs
.map((log) => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined);
const siteResourceIds = logs
.filter((log) => log.resourceId == null && log.siteResourceId != null)
.map((log) => log.siteResourceId)
.filter((id): id is number => id !== null && id !== undefined);
const userIds = [
...new Set(
logs
.map((log) => log.userId)
.filter((id): id is string => id !== null && id !== undefined)
)
];
const virtualApiKeyIds = [
...new Set(
logs
.map((log) => log.virtualApiKeyId)
.filter((id): id is string => id !== null && id !== undefined)
)
];
const providerMap = new Map<
number,
{ name: string | null; type: string | null }
>();
if (providerIds.length > 0) {
const providerDetails = await primaryDb
.select({
providerId: aiProviders.providerId,
name: aiProviders.name,
type: aiProviders.type
})
.from(aiProviders)
.where(
inArray(
aiProviders.providerId,
providerIds.filter(
(id): id is number => id !== null && id !== undefined
)
)
);
for (const p of providerDetails) {
providerMap.set(p.providerId, { name: p.name, type: p.type });
}
}
const resourceMap = new Map<
number,
{ name: string | null; niceId: string | null }
>();
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
for (const r of resourceDetails) {
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
}
}
const siteResourceMap = new Map<
number,
{ name: string | null; niceId: string | null }
>();
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name,
niceId: siteResources.niceId
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of siteResourceDetails) {
siteResourceMap.set(r.siteResourceId, {
name: r.name,
niceId: r.niceId
});
}
}
const userMap = new Map<string, string | null>();
if (userIds.length > 0) {
const userDetails = await db
.select({ userId: users.userId, email: users.email })
.from(users)
.where(inArray(users.userId, userIds));
for (const u of userDetails) {
userMap.set(u.userId, u.email);
}
}
const virtualApiKeyMap = new Map<
string,
{ name: string | null; lastChars: string }
>();
if (virtualApiKeyIds.length > 0) {
const virtualApiKeyDetails = await db
.select({
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
name: virtualApiKeys.name,
lastChars: virtualApiKeys.lastChars
})
.from(virtualApiKeys)
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
for (const k of virtualApiKeyDetails) {
virtualApiKeyMap.set(k.virtualApiKeyId, {
name: k.name,
lastChars: k.lastChars
});
}
}
const usageMap = new Map<
string,
{
promptTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
completionTokens: number;
reasoningTokens: number;
totalTokens: number;
costUsd: number | null;
estimated: boolean;
}
>();
const sessionIds = logs.map((log) => log.sessionId);
if (sessionIds.length > 0) {
const usageDetails = await logsDb
.select({
sessionId: aiUsageRecords.sessionId,
promptTokens: aiUsageRecords.promptTokens,
cacheReadTokens: aiUsageRecords.cacheReadTokens,
cacheWriteTokens: aiUsageRecords.cacheWriteTokens,
completionTokens: aiUsageRecords.completionTokens,
reasoningTokens: aiUsageRecords.reasoningTokens,
totalTokens: aiUsageRecords.totalTokens,
costUsd: aiUsageRecords.costUsd,
estimated: aiUsageRecords.estimated
})
.from(aiUsageRecords)
.where(inArray(aiUsageRecords.sessionId, sessionIds));
for (const u of usageDetails) {
if (!u.sessionId) continue;
usageMap.set(u.sessionId, {
promptTokens: u.promptTokens,
cacheReadTokens: u.cacheReadTokens,
cacheWriteTokens: u.cacheWriteTokens,
completionTokens: u.completionTokens,
reasoningTokens: u.reasoningTokens,
totalTokens: u.totalTokens,
costUsd: u.costUsd,
estimated: u.estimated
});
}
}
return logs.map((log) => {
const provider = log.providerId
? providerMap.get(log.providerId)
: null;
let resourceId = log.resourceId;
let resourceName: string | null = null;
let resourceNiceId: string | null = null;
let resourceType: "public" | "site" | null = null;
if (log.resourceId != null) {
const details = resourceMap.get(log.resourceId);
resourceName = details?.name ?? null;
resourceNiceId = details?.niceId ?? null;
resourceType = "public";
} else if (log.siteResourceId != null) {
const details = siteResourceMap.get(log.siteResourceId);
resourceId = log.siteResourceId;
resourceName = details?.name ?? null;
resourceNiceId = details?.niceId ?? null;
resourceType = "site";
}
return {
...log,
resourceId,
resourceType,
providerName: provider?.name ?? null,
providerType: provider?.type ?? null,
resourceName,
resourceNiceId,
userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null,
virtualApiKeyName: log.virtualApiKeyId
? (virtualApiKeyMap.get(log.virtualApiKeyId)?.name ?? null)
: null,
virtualApiKeyLastChars: log.virtualApiKeyId
? (virtualApiKeyMap.get(log.virtualApiKeyId)?.lastChars ?? null)
: null,
usage: usageMap.get(log.sessionId) ?? null
};
});
}
export function countAiSessionQuery(data: Q) {
return logsDb
.select({ count: count() })
.from(aiSessionLog)
.where(getWhere(data));
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/ai",
description: "Query the AI gateway session log for an organization",
tags: [OpenAPITags.Logs],
request: {
query: queryAiSessionLogsQuery,
params: queryAiSessionLogsParams
},
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()
})
}
}
}
}
});
async function queryUniqueFilterAttributes(
timeStart: number,
timeEnd: number,
orgId: string
) {
const baseConditions = and(
gt(aiSessionLog.createdAt, timeStart),
lt(aiSessionLog.createdAt, timeEnd),
eq(aiSessionLog.orgId, orgId)
);
const DISTINCT_LIMIT = 500;
const [
uniqueProviders,
uniqueUsers,
uniqueResources,
uniqueSiteResources,
uniqueModels,
uniqueVirtualApiKeys
] = await Promise.all([
logsDb
.selectDistinct({ id: aiSessionLog.providerId })
.from(aiSessionLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ userId: aiSessionLog.userId })
.from(aiSessionLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ id: aiSessionLog.resourceId })
.from(aiSessionLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ id: aiSessionLog.siteResourceId })
.from(aiSessionLog)
.where(and(baseConditions, isNull(aiSessionLog.resourceId)))
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ model: aiSessionLog.requestedModel })
.from(aiSessionLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ id: aiSessionLog.virtualApiKeyId })
.from(aiSessionLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1)
]);
const models = uniqueModels
.map((row) => row.model)
.filter((model): model is string => model !== null);
const providerIds = uniqueProviders
.map((row) => row.id)
.filter((id): id is number => id !== null);
let providers: Array<{ id: number; name: string | null }> = [];
if (providerIds.length > 0) {
const providerDetails = await primaryDb
.select({
providerId: aiProviders.providerId,
name: aiProviders.name
})
.from(aiProviders)
.where(inArray(aiProviders.providerId, providerIds));
providers = providerDetails.map((p) => ({
id: p.providerId,
name: p.name
}));
}
const userIds = uniqueUsers
.map((row) => row.userId)
.filter((id): id is string => id !== null);
let userList: Array<{ id: string; email: string | null }> = [];
if (userIds.length > 0) {
const userDetails = await db
.select({ userId: users.userId, email: users.email })
.from(users)
.where(inArray(users.userId, userIds));
userList = userDetails.map((u) => ({ id: u.userId, email: u.email }));
}
const resourceIds = uniqueResources
.map((row) => row.id)
.filter((id): id is number => id !== null);
const siteResourceIds = uniqueSiteResources
.map((row) => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = [
...resourcesWithNames,
...resourceDetails.map((r) => ({
id: r.resourceId,
name: r.name
}))
];
}
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
resourcesWithNames = [
...resourcesWithNames,
...siteResourceDetails.map((r) => ({
id: r.siteResourceId,
name: r.name
}))
];
}
const virtualApiKeyIds = uniqueVirtualApiKeys
.map((row) => row.id)
.filter((id): id is string => id !== null);
let virtualApiKeyList: Array<{
id: string;
name: string | null;
lastChars: string | null;
}> = [];
if (virtualApiKeyIds.length > 0) {
const virtualApiKeyDetails = await primaryDb
.select({
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
name: virtualApiKeys.name,
lastChars: virtualApiKeys.lastChars
})
.from(virtualApiKeys)
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
virtualApiKeyList = virtualApiKeyDetails.map((k) => ({
id: k.virtualApiKeyId,
name: k.name,
lastChars: k.lastChars
}));
}
return {
providers: sortNamedFilterOptions(providers),
resources: sortNamedFilterOptions(resourcesWithNames),
users: userList,
virtualApiKeys: virtualApiKeyList,
models: models.sort()
};
}
export async function queryAiSessionLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAiSessionLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryAiSessionLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = { ...parsedQuery.data, ...parsedParams.data };
const baseQuery = queryAiSession(data);
const logsRaw = (
await baseQuery.limit(data.limit).offset(data.offset)
).map(decompressAiSessionLogRow);
const log = await enrichWithDetails(logsRaw);
const totalCountResult = await countAiSessionQuery(data);
const totalCount = totalCountResult[0].count;
const filterAttributes = await queryUniqueFilterAttributes(
data.timeStart,
data.timeEnd,
data.orgId
);
return response<QueryAiSessionLogResponse>(res, {
data: {
log,
pagination: {
total: totalCount,
limit: data.limit,
offset: data.offset
},
filterAttributes
},
success: true,
error: false,
message: "AI session logs retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,323 @@
import {
db,
logsDb,
aiUsageRecords,
aiProviders,
resources,
siteResources,
users,
roles,
userOrgRoles,
virtualApiKeys
} from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { and, eq, gte, lte, inArray, isNull, not } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import response from "@server/lib/response";
import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { DISTINCT_LIMIT } from "./aiUsageAnalyticsShared";
const queryAiUsageFilterOptionsQuery = z.object({
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString()),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => new Date().toISOString())
});
const queryAiUsageFilterOptionsParams = z.object({
orgId: z.string()
});
const queryAiUsageFilterOptionsCombined = queryAiUsageFilterOptionsQuery.merge(
queryAiUsageFilterOptionsParams
);
type Q = z.infer<typeof queryAiUsageFilterOptionsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
items: T[]
): T[] {
return [...items].sort((a, b) => {
const nameA = a.name ?? "";
const nameB = b.name ?? "";
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return a.id - b.id;
});
}
async function query(data: Q) {
const baseConditions = and(
eq(aiUsageRecords.orgId, data.orgId),
gte(aiUsageRecords.createdAt, data.timeStart),
lte(aiUsageRecords.createdAt, data.timeEnd)
);
const [
uniqueProviders,
uniqueModels,
uniqueResources,
uniqueSiteResources,
uniqueUsers,
uniqueVirtualApiKeys
] = await Promise.all([
logsDb
.selectDistinct({ id: aiUsageRecords.providerId })
.from(aiUsageRecords)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ model: aiUsageRecords.requestedModel })
.from(aiUsageRecords)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ id: aiUsageRecords.resourceId })
.from(aiUsageRecords)
.where(and(baseConditions, not(isNull(aiUsageRecords.resourceId))))
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ id: aiUsageRecords.siteResourceId })
.from(aiUsageRecords)
.where(
and(
baseConditions,
isNull(aiUsageRecords.resourceId),
not(isNull(aiUsageRecords.siteResourceId))
)
)
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ userId: aiUsageRecords.userId })
.from(aiUsageRecords)
.where(and(baseConditions, not(isNull(aiUsageRecords.userId))))
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ id: aiUsageRecords.virtualApiKeyId })
.from(aiUsageRecords)
.where(
and(baseConditions, not(isNull(aiUsageRecords.virtualApiKeyId)))
)
.limit(DISTINCT_LIMIT + 1)
]);
const models = uniqueModels
.map((row) => row.model)
.filter((model): model is string => model !== null)
.sort();
const providerIds = uniqueProviders
.map((row) => row.id)
.filter((id): id is number => id !== null);
let providers: Array<{ id: number; name: string | null }> = [];
if (providerIds.length > 0) {
const providerDetails = await db
.select({
providerId: aiProviders.providerId,
name: aiProviders.name
})
.from(aiProviders)
.where(inArray(aiProviders.providerId, providerIds));
providers = providerDetails.map((p) => ({
id: p.providerId,
name: p.name
}));
}
const resourceIds = uniqueResources
.map((row) => row.id)
.filter((id): id is number => id !== null);
const siteResourceIds = uniqueSiteResources
.map((row) => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) {
const resourceDetails = await db
.select({ resourceId: resources.resourceId, name: resources.name })
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = resourcesWithNames.concat(
resourceDetails.map((r) => ({ id: r.resourceId, name: r.name }))
);
}
if (siteResourceIds.length > 0) {
const siteResourceDetails = await db
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
resourcesWithNames = resourcesWithNames.concat(
siteResourceDetails.map((r) => ({
id: r.siteResourceId,
name: r.name
}))
);
}
const userIds = uniqueUsers
.map((row) => row.userId)
.filter((id): id is string => id !== null);
let userList: Array<{ id: string; email: string | null }> = [];
let roleList: Array<{ id: number; name: string | null }> = [];
if (userIds.length > 0) {
const userDetails = await db
.select({ userId: users.userId, email: users.email })
.from(users)
.where(inArray(users.userId, userIds));
userList = userDetails.map((u) => ({ id: u.userId, email: u.email }));
const roleRows = await db
.select({ roleId: roles.roleId, name: roles.name })
.from(userOrgRoles)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.orgId, data.orgId),
inArray(userOrgRoles.userId, userIds)
)
);
const roleMap = new Map<number, string | null>();
for (const r of roleRows) {
roleMap.set(r.roleId, r.name);
}
roleList = [...roleMap.entries()].map(([id, name]) => ({ id, name }));
}
const virtualApiKeyIds = uniqueVirtualApiKeys
.map((row) => row.id)
.filter((id): id is string => id !== null);
let virtualApiKeyList: Array<{
id: string;
name: string | null;
lastChars: string;
}> = [];
if (virtualApiKeyIds.length > 0) {
const virtualApiKeyDetails = await db
.select({
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
name: virtualApiKeys.name,
lastChars: virtualApiKeys.lastChars
})
.from(virtualApiKeys)
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
virtualApiKeyList = virtualApiKeyDetails.map((k) => ({
id: k.virtualApiKeyId,
name: k.name,
lastChars: k.lastChars
}));
}
return {
providers: sortNamedFilterOptions(providers),
resources: sortNamedFilterOptions(resourcesWithNames),
roles: sortNamedFilterOptions(roleList),
users: userList,
virtualApiKeys: virtualApiKeyList,
models
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/ai/usage/filters",
description:
"Query the distinct filter options (providers, models, resources, roles, users) available for AI usage analytics within a time range",
tags: [OpenAPITags.Logs],
request: {
query: queryAiUsageFilterOptionsQuery,
params: queryAiUsageFilterOptionsParams
},
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()
})
}
}
}
}
});
export type QueryAiUsageFilterOptionsResponse = Awaited<
ReturnType<typeof query>
>;
export async function queryAiUsageFilterOptions(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAiUsageFilterOptionsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryAiUsageFilterOptionsParams.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = await query({ ...parsedQuery.data, ...parsedParams.data });
return response<QueryAiUsageFilterOptionsResponse>(res, {
data,
success: true,
error: false,
message: "AI usage filter options retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,228 @@
import { logsDb, aiUsageRecords } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { and, count, desc, eq, sql } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import response from "@server/lib/response";
import logger from "@server/logger";
import {
aiUsageAnalyticsFiltersQuery,
aiUsageAnalyticsParams,
aiUsageAnalyticsCombined,
buildAiUsageWhere,
resolveRoleUserIds,
dayBucketExpr,
pickTopNKeys,
bucketTopNPerDay,
DISTINCT_LIMIT,
type AiUsageAnalyticsQuery
} from "./aiUsageAnalyticsShared";
type Q = AiUsageAnalyticsQuery;
async function query(data: Q) {
const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId);
const baseConditions = buildAiUsageWhere(data, roleUserIds);
const [totalsRow] = await logsDb
.select({
requests: count(),
promptTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`,
cacheReadTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`,
cacheWriteTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`,
completionTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`,
reasoningTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)`,
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
estimatedRequests: sql<number>`SUM(CASE WHEN ${aiUsageRecords.estimated} THEN 1 ELSE 0 END)`
})
.from(aiUsageRecords)
.where(baseConditions);
const dayExpr = dayBucketExpr();
const requestsPerDay = await logsDb
.select({
day: dayExpr.as("day"),
requests: count()
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(dayExpr)
.orderBy(dayExpr);
const tokensPerDay = await logsDb
.select({
day: dayExpr.as("day"),
promptTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`,
cacheReadTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`,
cacheWriteTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`,
completionTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`,
reasoningTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(dayExpr)
.orderBy(dayExpr);
const costPerDay = await logsDb
.select({
day: dayExpr.as("day"),
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(dayExpr)
.orderBy(dayExpr);
const modelByDay = await logsDb
.select({
day: dayExpr.as("day"),
model: aiUsageRecords.requestedModel,
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(dayExpr, aiUsageRecords.requestedModel)
.orderBy(dayExpr);
const modelCostTotals = new Map<string, number>();
const modelTokenTotals = new Map<string, number>();
for (const row of modelByDay) {
modelCostTotals.set(
row.model,
(modelCostTotals.get(row.model) ?? 0) + row.cost
);
modelTokenTotals.set(
row.model,
(modelTokenTotals.get(row.model) ?? 0) + row.tokens
);
}
const topModelsByCost = pickTopNKeys(modelCostTotals);
const topModelsByTokens = pickTopNKeys(modelTokenTotals);
const modelCostPerDay = bucketTopNPerDay(
modelByDay.map((r) => ({ day: r.day, key: r.model, value: r.cost })),
topModelsByCost
);
const modelTokensPerDay = bucketTopNPerDay(
modelByDay.map((r) => ({ day: r.day, key: r.model, value: r.tokens })),
topModelsByTokens
);
const topModelsRaw = await logsDb
.select({
model: aiUsageRecords.requestedModel,
requests: count(),
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(aiUsageRecords.requestedModel)
.orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`))
.limit(DISTINCT_LIMIT + 1);
if (topModelsRaw.length > DISTINCT_LIMIT) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Too many distinct models. Please narrow your query."
);
}
return {
totalRequests: totalsRow.requests,
totalTokens: totalsRow.totalTokens,
totalCost: totalsRow.costUsd,
estimatedPercent:
totalsRow.requests > 0
? (totalsRow.estimatedRequests / totalsRow.requests) * 100
: 0,
tokenBreakdown: {
promptTokens: totalsRow.promptTokens,
cacheReadTokens: totalsRow.cacheReadTokens,
cacheWriteTokens: totalsRow.cacheWriteTokens,
completionTokens: totalsRow.completionTokens,
reasoningTokens: totalsRow.reasoningTokens
},
requestsPerDay,
tokensPerDay,
costPerDay,
modelCostPerDay,
modelTokensPerDay,
topModels: topModelsRaw
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/ai/usage/overview",
description: "Query the AI usage analytics overview for an organization",
tags: [OpenAPITags.Logs],
request: {
query: aiUsageAnalyticsFiltersQuery,
params: aiUsageAnalyticsParams
},
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()
})
}
}
}
}
});
export type QueryAiUsageOverviewResponse = Awaited<ReturnType<typeof query>>;
export async function queryAiUsageOverview(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error))
);
}
const parsedParams = aiUsageAnalyticsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error))
);
}
const data = await query({ ...parsedQuery.data, ...parsedParams.data });
return response<QueryAiUsageOverviewResponse>(res, {
data,
success: true,
error: false,
message: "AI usage overview retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,198 @@
import { db, logsDb, aiUsageRecords, aiProviders } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { count, desc, inArray, sql } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import response from "@server/lib/response";
import logger from "@server/logger";
import {
aiUsageAnalyticsFiltersQuery,
aiUsageAnalyticsParams,
buildAiUsageWhere,
resolveRoleUserIds,
dayBucketExpr,
pickTopNKeys,
bucketTopNPerDay,
DISTINCT_LIMIT,
type AiUsageAnalyticsQuery
} from "./aiUsageAnalyticsShared";
type Q = AiUsageAnalyticsQuery;
async function query(data: Q) {
const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId);
const baseConditions = buildAiUsageWhere(data, roleUserIds);
const dayExpr = dayBucketExpr();
const providerByDay = await logsDb
.select({
day: dayExpr.as("day"),
providerId: aiUsageRecords.providerId,
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(dayExpr, aiUsageRecords.providerId)
.orderBy(dayExpr);
const costTotals = new Map<string, number>();
const tokenTotals = new Map<string, number>();
for (const row of providerByDay) {
const key = String(row.providerId);
costTotals.set(key, (costTotals.get(key) ?? 0) + row.cost);
tokenTotals.set(key, (tokenTotals.get(key) ?? 0) + row.tokens);
}
const topByCost = pickTopNKeys(costTotals);
const topByTokens = pickTopNKeys(tokenTotals);
const providerCostPerDay = bucketTopNPerDay(
providerByDay.map((r) => ({
day: r.day,
key: String(r.providerId),
value: r.cost
})),
topByCost
);
const providerTokensPerDay = bucketTopNPerDay(
providerByDay.map((r) => ({
day: r.day,
key: String(r.providerId),
value: r.tokens
})),
topByTokens
);
const topProvidersRaw = await logsDb
.select({
providerId: aiUsageRecords.providerId,
requests: count(),
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(aiUsageRecords.providerId)
.orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`))
.limit(DISTINCT_LIMIT + 1);
if (topProvidersRaw.length > DISTINCT_LIMIT) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Too many distinct providers. Please narrow your query."
);
}
const providerIds = topProvidersRaw.map((r) => r.providerId);
const nameMap = new Map<number, string | null>();
if (providerIds.length > 0) {
const providerDetails = await db
.select({
providerId: aiProviders.providerId,
name: aiProviders.name
})
.from(aiProviders)
.where(
inArray(
aiProviders.providerId,
providerIds.filter((id): id is number => id !== null)
)
);
for (const p of providerDetails) {
nameMap.set(p.providerId, p.name);
}
}
const topProviders = topProvidersRaw.map((r) => ({
providerId: r.providerId,
name: r.providerId ? (nameMap.get(r.providerId) ?? null) : null,
requests: r.requests,
totalTokens: r.totalTokens,
costUsd: r.costUsd
}));
return {
providerCostPerDay,
providerTokensPerDay,
topProviders
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/ai/usage/providers",
description:
"Query the AI usage analytics provider breakdown for an organization",
tags: [OpenAPITags.Logs],
request: {
query: aiUsageAnalyticsFiltersQuery,
params: aiUsageAnalyticsParams
},
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()
})
}
}
}
}
});
export type QueryAiUsageProvidersResponse = Awaited<ReturnType<typeof query>>;
export async function queryAiUsageProviders(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = aiUsageAnalyticsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = await query({ ...parsedQuery.data, ...parsedParams.data });
return response<QueryAiUsageProvidersResponse>(res, {
data,
success: true,
error: false,
message: "AI usage provider breakdown retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,224 @@
import { db, logsDb, aiUsageRecords, resources, siteResources } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { count, desc, inArray, sql } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import response from "@server/lib/response";
import logger from "@server/logger";
import {
aiUsageAnalyticsFiltersQuery,
aiUsageAnalyticsParams,
buildAiUsageWhere,
resolveRoleUserIds,
dayBucketExpr,
pickTopNKeys,
bucketTopNPerDay,
DISTINCT_LIMIT,
type AiUsageAnalyticsQuery
} from "./aiUsageAnalyticsShared";
type Q = AiUsageAnalyticsQuery;
// Composite key namespacing resourceId ("r-") vs siteResourceId ("s-") since
// the two id spaces are independent and can overlap numerically. Uses a dash
// rather than a colon so the key stays safe to use as a CSS custom-property
// name suffix (e.g. --color-r-1) on the client.
function resourceKey(resourceId: number | null, siteResourceId: number | null) {
if (resourceId != null) return `r-${resourceId}`;
if (siteResourceId != null) return `s-${siteResourceId}`;
return "none";
}
async function query(data: Q) {
const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId);
const baseConditions = buildAiUsageWhere(data, roleUserIds);
const dayExpr = dayBucketExpr();
const resourceByDay = await logsDb
.select({
day: dayExpr.as("day"),
resourceId: aiUsageRecords.resourceId,
siteResourceId: aiUsageRecords.siteResourceId,
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(dayExpr, aiUsageRecords.resourceId, aiUsageRecords.siteResourceId)
.orderBy(dayExpr);
const costTotals = new Map<string, number>();
const tokenTotals = new Map<string, number>();
for (const row of resourceByDay) {
const key = resourceKey(row.resourceId, row.siteResourceId);
costTotals.set(key, (costTotals.get(key) ?? 0) + row.cost);
tokenTotals.set(key, (tokenTotals.get(key) ?? 0) + row.tokens);
}
const topByCost = pickTopNKeys(costTotals);
const topByTokens = pickTopNKeys(tokenTotals);
const resourceCostPerDay = bucketTopNPerDay(
resourceByDay.map((r) => ({
day: r.day,
key: resourceKey(r.resourceId, r.siteResourceId),
value: r.cost
})),
topByCost
);
const resourceTokensPerDay = bucketTopNPerDay(
resourceByDay.map((r) => ({
day: r.day,
key: resourceKey(r.resourceId, r.siteResourceId),
value: r.tokens
})),
topByTokens
);
const topResourcesRaw = await logsDb
.select({
resourceId: aiUsageRecords.resourceId,
siteResourceId: aiUsageRecords.siteResourceId,
requests: count(),
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(aiUsageRecords.resourceId, aiUsageRecords.siteResourceId)
.orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`))
.limit(DISTINCT_LIMIT + 1);
if (topResourcesRaw.length > DISTINCT_LIMIT) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Too many distinct resources. Please narrow your query."
);
}
const resourceIds = topResourcesRaw
.map((r) => r.resourceId)
.filter((id): id is number => id !== null);
const siteResourceIds = topResourcesRaw
.map((r) => r.siteResourceId)
.filter((id): id is number => id !== null);
const nameMap = new Map<string, string | null>();
if (resourceIds.length > 0) {
const resourceDetails = await db
.select({ resourceId: resources.resourceId, name: resources.name })
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
for (const r of resourceDetails) {
nameMap.set(`r-${r.resourceId}`, r.name);
}
}
if (siteResourceIds.length > 0) {
const siteResourceDetails = await db
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of siteResourceDetails) {
nameMap.set(`s-${r.siteResourceId}`, r.name);
}
}
const topResources = topResourcesRaw.map((r) => {
const key = resourceKey(r.resourceId, r.siteResourceId);
return {
key,
resourceId: r.resourceId,
siteResourceId: r.siteResourceId,
type:
r.resourceId != null
? ("public" as const)
: r.siteResourceId != null
? ("site" as const)
: null,
name: nameMap.get(key) ?? null,
requests: r.requests,
totalTokens: r.totalTokens,
costUsd: r.costUsd
};
});
return {
resourceCostPerDay,
resourceTokensPerDay,
topResources
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/ai/usage/resources",
description: "Query the AI usage analytics resource breakdown for an organization",
tags: [OpenAPITags.Logs],
request: {
query: aiUsageAnalyticsFiltersQuery,
params: aiUsageAnalyticsParams
},
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()
})
}
}
}
}
});
export type QueryAiUsageResourcesResponse = Awaited<ReturnType<typeof query>>;
export async function queryAiUsageResources(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error))
);
}
const parsedParams = aiUsageAnalyticsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error))
);
}
const data = await query({ ...parsedQuery.data, ...parsedParams.data });
return response<QueryAiUsageResourcesResponse>(res, {
data,
success: true,
error: false,
message: "AI usage resource breakdown retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,278 @@
import {
db,
logsDb,
aiUsageRecords,
users,
roles,
userOrgRoles
} from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import response from "@server/lib/response";
import logger from "@server/logger";
import {
aiUsageAnalyticsFiltersQuery,
aiUsageAnalyticsParams,
buildAiUsageWhere,
resolveRoleUserIds,
dayBucketExpr,
pickTopNKeys,
bucketTopNPerDay,
DISTINCT_LIMIT,
type AiUsageAnalyticsQuery
} from "./aiUsageAnalyticsShared";
type Q = AiUsageAnalyticsQuery;
const UNKNOWN_USER_KEY = "unknown";
async function query(data: Q) {
const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId);
const baseConditions = buildAiUsageWhere(data, roleUserIds);
const dayExpr = dayBucketExpr();
// Per (day, user) is the common granularity both the user trend charts and
// the role trend charts are built from - a usage record only stores
// userId, so role totals are derived by expanding each user's usage into
// every role they hold in the org (per-role double counting for
// multi-role users is expected/accepted).
const userByDay = await logsDb
.select({
day: dayExpr.as("day"),
userId: aiUsageRecords.userId,
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(dayExpr, aiUsageRecords.userId)
.orderBy(dayExpr);
const userTotalsRaw = await logsDb
.select({
userId: aiUsageRecords.userId,
requests: count(),
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(aiUsageRecords.userId)
.orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`))
.limit(DISTINCT_LIMIT + 1);
if (userTotalsRaw.length > DISTINCT_LIMIT) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Too many distinct users. Please narrow your query."
);
}
const userIds = userTotalsRaw
.map((r) => r.userId)
.filter((id): id is string => id !== null);
const emailMap = new Map<string, string | null>();
if (userIds.length > 0) {
const userDetails = await db
.select({ userId: users.userId, email: users.email })
.from(users)
.where(inArray(users.userId, userIds));
for (const u of userDetails) {
emailMap.set(u.userId, u.email);
}
}
const topUsers = userTotalsRaw.map((r) => ({
userId: r.userId,
email: r.userId ? emailMap.get(r.userId) ?? null : null,
requests: r.requests,
totalTokens: r.totalTokens,
costUsd: r.costUsd
}));
const userCostTotals = new Map<string, number>();
const userTokenTotals = new Map<string, number>();
for (const row of userByDay) {
const key = row.userId ?? UNKNOWN_USER_KEY;
userCostTotals.set(key, (userCostTotals.get(key) ?? 0) + row.cost);
userTokenTotals.set(key, (userTokenTotals.get(key) ?? 0) + row.tokens);
}
const topUsersByCost = pickTopNKeys(userCostTotals);
const topUsersByTokens = pickTopNKeys(userTokenTotals);
const userCostPerDay = bucketTopNPerDay(
userByDay.map((r) => ({
day: r.day,
key: r.userId ?? UNKNOWN_USER_KEY,
value: r.cost
})),
topUsersByCost
);
const userTokensPerDay = bucketTopNPerDay(
userByDay.map((r) => ({
day: r.day,
key: r.userId ?? UNKNOWN_USER_KEY,
value: r.tokens
})),
topUsersByTokens
);
// Resolve every user's role membership(s) in this org so usage can be
// expanded into per-role totals.
const userToRoles = new Map<string, { roleId: number; name: string | null }[]>();
if (userIds.length > 0) {
const roleRows = await db
.select({
userId: userOrgRoles.userId,
roleId: roles.roleId,
name: roles.name
})
.from(userOrgRoles)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.orgId, data.orgId),
inArray(userOrgRoles.userId, userIds)
)
);
for (const row of roleRows) {
const existing = userToRoles.get(row.userId) ?? [];
existing.push({ roleId: row.roleId, name: row.name });
userToRoles.set(row.userId, existing);
}
}
const roleTotals = new Map<
number,
{ name: string | null; requests: number; totalTokens: number; costUsd: number }
>();
for (const r of userTotalsRaw) {
if (!r.userId) continue;
const userRoles = userToRoles.get(r.userId) ?? [];
for (const role of userRoles) {
const existing = roleTotals.get(role.roleId) ?? {
name: role.name,
requests: 0,
totalTokens: 0,
costUsd: 0
};
existing.requests += r.requests;
existing.totalTokens += r.totalTokens;
existing.costUsd += r.costUsd;
roleTotals.set(role.roleId, existing);
}
}
const topRoles = [...roleTotals.entries()]
.map(([roleId, v]) => ({ roleId, ...v }))
.sort((a, b) => b.costUsd - a.costUsd);
const roleCostRows: { day: string; key: string; value: number }[] = [];
const roleTokenRows: { day: string; key: string; value: number }[] = [];
for (const row of userByDay) {
if (!row.userId) continue;
const userRoles = userToRoles.get(row.userId) ?? [];
for (const role of userRoles) {
roleCostRows.push({ day: row.day, key: String(role.roleId), value: row.cost });
roleTokenRows.push({ day: row.day, key: String(role.roleId), value: row.tokens });
}
}
const roleCostTotals = new Map<string, number>();
const roleTokenTotals = new Map<string, number>();
for (const row of roleCostRows) {
roleCostTotals.set(row.key, (roleCostTotals.get(row.key) ?? 0) + row.value);
}
for (const row of roleTokenRows) {
roleTokenTotals.set(row.key, (roleTokenTotals.get(row.key) ?? 0) + row.value);
}
const topRolesByCost = pickTopNKeys(roleCostTotals);
const topRolesByTokens = pickTopNKeys(roleTokenTotals);
const roleCostPerDay = bucketTopNPerDay(roleCostRows, topRolesByCost);
const roleTokensPerDay = bucketTopNPerDay(roleTokenRows, topRolesByTokens);
return {
topUsers,
userCostPerDay,
userTokensPerDay,
topRoles,
roleCostPerDay,
roleTokensPerDay
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/ai/usage/users-roles",
description:
"Query the AI usage analytics user and role breakdown for an organization",
tags: [OpenAPITags.Logs],
request: {
query: aiUsageAnalyticsFiltersQuery,
params: aiUsageAnalyticsParams
},
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()
})
}
}
}
}
});
export type QueryAiUsageUsersRolesResponse = Awaited<ReturnType<typeof query>>;
export async function queryAiUsageUsersRoles(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error))
);
}
const parsedParams = aiUsageAnalyticsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error))
);
}
const data = await query({ ...parsedQuery.data, ...parsedParams.data });
return response<QueryAiUsageUsersRolesResponse>(res, {
data,
success: true,
error: false,
message: "AI usage user/role breakdown retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,222 @@
import { db, logsDb, aiUsageRecords, virtualApiKeys } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { count, desc, inArray, sql } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import response from "@server/lib/response";
import logger from "@server/logger";
import {
aiUsageAnalyticsFiltersQuery,
aiUsageAnalyticsParams,
buildAiUsageWhere,
resolveRoleUserIds,
dayBucketExpr,
pickTopNKeys,
bucketTopNPerDay,
DISTINCT_LIMIT,
type AiUsageAnalyticsQuery
} from "./aiUsageAnalyticsShared";
type Q = AiUsageAnalyticsQuery;
const UNKNOWN_VIRTUAL_API_KEY_KEY = "unknown";
async function query(data: Q) {
const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId);
const baseConditions = buildAiUsageWhere(data, roleUserIds);
const dayExpr = dayBucketExpr();
const virtualApiKeyByDay = await logsDb
.select({
day: dayExpr.as("day"),
virtualApiKeyId: aiUsageRecords.virtualApiKeyId,
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(dayExpr, aiUsageRecords.virtualApiKeyId)
.orderBy(dayExpr);
const virtualApiKeyTotalsRaw = await logsDb
.select({
virtualApiKeyId: aiUsageRecords.virtualApiKeyId,
requests: count(),
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(aiUsageRecords.virtualApiKeyId)
.orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`))
.limit(DISTINCT_LIMIT + 1);
if (virtualApiKeyTotalsRaw.length > DISTINCT_LIMIT) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Too many distinct virtual API keys. Please narrow your query."
);
}
const virtualApiKeyIds = virtualApiKeyTotalsRaw
.map((r) => r.virtualApiKeyId)
.filter((id): id is string => id !== null);
const detailsMap = new Map<
string,
{ name: string | null; lastChars: string; kind: "user" | "manual" }
>();
if (virtualApiKeyIds.length > 0) {
const details = await db
.select({
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
name: virtualApiKeys.name,
lastChars: virtualApiKeys.lastChars,
kind: virtualApiKeys.kind
})
.from(virtualApiKeys)
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
for (const k of details) {
detailsMap.set(k.virtualApiKeyId, {
name: k.name,
lastChars: k.lastChars,
kind: k.kind
});
}
}
const topVirtualApiKeys = virtualApiKeyTotalsRaw.map((r) => {
const details = r.virtualApiKeyId
? detailsMap.get(r.virtualApiKeyId)
: undefined;
return {
virtualApiKeyId: r.virtualApiKeyId,
name: details?.name ?? null,
lastChars: details?.lastChars ?? null,
kind: details?.kind ?? null,
requests: r.requests,
totalTokens: r.totalTokens,
costUsd: r.costUsd
};
});
const virtualApiKeyCostTotals = new Map<string, number>();
const virtualApiKeyTokenTotals = new Map<string, number>();
for (const row of virtualApiKeyByDay) {
const key = row.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY;
virtualApiKeyCostTotals.set(
key,
(virtualApiKeyCostTotals.get(key) ?? 0) + row.cost
);
virtualApiKeyTokenTotals.set(
key,
(virtualApiKeyTokenTotals.get(key) ?? 0) + row.tokens
);
}
const topVirtualApiKeysByCost = pickTopNKeys(virtualApiKeyCostTotals);
const topVirtualApiKeysByTokens = pickTopNKeys(virtualApiKeyTokenTotals);
const virtualApiKeyCostPerDay = bucketTopNPerDay(
virtualApiKeyByDay.map((r) => ({
day: r.day,
key: r.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
value: r.cost
})),
topVirtualApiKeysByCost
);
const virtualApiKeyTokensPerDay = bucketTopNPerDay(
virtualApiKeyByDay.map((r) => ({
day: r.day,
key: r.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
value: r.tokens
})),
topVirtualApiKeysByTokens
);
return {
topVirtualApiKeys,
virtualApiKeyCostPerDay,
virtualApiKeyTokensPerDay
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/ai/usage/virtual-api-keys",
description:
"Query the AI usage analytics virtual API key breakdown for an organization",
tags: [OpenAPITags.Logs],
request: {
query: aiUsageAnalyticsFiltersQuery,
params: aiUsageAnalyticsParams
},
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()
})
}
}
}
}
});
export type QueryAiUsageVirtualApiKeysResponse = Awaited<
ReturnType<typeof query>
>;
export async function queryAiUsageVirtualApiKeys(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = aiUsageAnalyticsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = await query({ ...parsedQuery.data, ...parsedParams.data });
return response<QueryAiUsageVirtualApiKeysResponse>(res, {
data,
success: true,
error: false,
message:
"AI usage virtual API key breakdown retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -81,7 +81,27 @@ export const queryAccessAuditLogsQuery = z.strictObject({
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.pipe(z.int().nonnegative()),
ip: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
}, z.array(z.string()))
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by IP adresses"
})
});
export const queryRequestAuditLogsParams = z.object({
@@ -126,6 +146,9 @@ function getWhere(data: Q) {
data.path ? eq(requestAuditLog.path, data.path) : undefined,
data.action !== undefined
? eq(requestAuditLog.action, data.action)
: undefined,
data.ip && data.ip.length > 0
? inArray(requestAuditLog.ip, data.ip)
: undefined
);
}
@@ -158,7 +181,7 @@ export function queryRequest(data: Q) {
})
.from(requestAuditLog)
.where(getWhere(data))
.orderBy(desc(requestAuditLog.timestamp));
.orderBy(desc(requestAuditLog.timestamp), desc(requestAuditLog.id));
}
async function enrichWithResourceDetails(
+66
View File
@@ -94,6 +94,72 @@ export type QueryAccessAuditLogResponse = {
};
};
export type QueryAiSessionLogResponse = {
log: {
id: number;
sessionId: string;
orgId: string | null;
providerId: number | null;
providerName: string | null;
providerType: string | null;
capability: string;
resourceId: number | null;
siteResourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
resourceType: "public" | "site" | null;
userId: string | null;
userEmail: string | null;
virtualApiKeyId: string | null;
virtualApiKeyName: string | null;
virtualApiKeyLastChars: string | null;
requestedModel: string | null;
isStream: boolean;
requestBody: string | null;
responseBody: string | null;
normalizedRequest: string | null;
normalizedResponse: string | null;
truncated: boolean;
statusCode: number | null;
createdAt: number;
usage: {
promptTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
completionTokens: number;
reasoningTokens: number;
totalTokens: number;
costUsd: number | null;
estimated: boolean;
} | null;
}[];
pagination: {
total: number;
limit: number;
offset: number;
};
filterAttributes: {
providers: {
id: number;
name: string | null;
}[];
resources: {
id: number;
name: string | null;
}[];
users: {
id: string;
email: string | null;
}[];
virtualApiKeys: {
id: string;
name: string | null;
lastChars: string | null;
}[];
models: string[];
};
};
export type QueryConnectionAuditLogResponse = {
log: {
sessionId: string;
+15 -10
View File
@@ -16,18 +16,26 @@ export async function logout(
next: NextFunction
): Promise<any> {
const { user, session } = await verifySession(req);
const isSecure = req.protocol === "https";
// Always clear the session cookie so logout is idempotent, even when
// the session is already missing or invalid
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
if (!user || !session) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Log out failed because missing or invalid session. IP: ${req.ip}.`
`Log out with missing or invalid session. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"You must be logged in to sign out"
)
);
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Logged out successfully",
status: HttpCode.OK
});
}
try {
@@ -37,9 +45,6 @@ export async function logout(
logger.error("Failed to invalidate session", error);
}
const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
return response<null>(res, {
data: null,
success: true,
+8 -3
View File
@@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response";
import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
import { createDate } from "oslo";
import logger from "@server/logger";
@@ -49,7 +49,12 @@ export async function requestPasswordReset(
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email));
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
);
if (!existingUser || !existingUser.length) {
await randomDelay(2000);
@@ -99,7 +104,7 @@ export async function requestPasswordReset(
});
});
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${encodeURIComponent(email)}&token=${token}`;
if (!config.getRawConfig().email) {
logger.info(
+9 -4
View File
@@ -533,18 +533,23 @@ export async function startAuthentication(
// If email is provided, get security keys for that specific user
if (email) {
const [user] = await db
const matchingUsers = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
.where(
and(
eq(users.email, email.toLowerCase()),
eq(users.type, UserType.Internal)
)
);
if (!user || user.type !== UserType.Internal) {
if (matchingUsers.length !== 1) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials")
);
}
const user = matchingUsers[0];
userId = user.userId;
const userSecurityKeys = await db
+49 -38
View File
@@ -42,54 +42,62 @@ export async function setServerAdmin(
const { email, password, setupToken } = parsedBody.data;
// Validate setup token
const [validToken] = await db
.select()
.from(setupTokens)
.where(
and(
eq(setupTokens.token, setupToken),
eq(setupTokens.used, false)
)
);
if (!validToken) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid or expired setup token"
)
);
}
const [existing] = await db
.select()
.from(users)
.where(eq(users.serverAdmin, true));
if (existing) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Server admin already exists"
)
);
}
const passwordHash = await hashPassword(password);
const userId = generateId(15);
await db.transaction(async (trx) => {
// Mark the token as used
await trx
const consumed = await trx
.update(setupTokens)
.set({
used: true,
dateUsed: moment().toISOString()
})
.where(eq(setupTokens.tokenId, validToken.tokenId));
.where(
and(
eq(setupTokens.token, setupToken),
eq(setupTokens.used, false)
)
)
.returning({ tokenId: setupTokens.tokenId });
if (!consumed.length) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Invalid setup token"
);
}
const [existingAdmin] = await trx
.select({ userId: users.userId })
.from(users)
.where(eq(users.serverAdmin, true))
.limit(1);
if (existingAdmin) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Server admin already exists"
);
}
const [existingUser] = await trx
.select({ userId: users.userId })
.from(users)
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
)
.limit(1);
if (existingUser) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address already exists"
);
}
// Create the server admin user
await trx.insert(users).values({
userId: userId,
email: email,
@@ -111,6 +119,9 @@ export async function setServerAdmin(
status: HttpCode.OK
});
} catch (e) {
if (createHttpError.isHttpError(e)) {
return next(e);
}
logger.error(e);
return next(
createHttpError(
+1 -1
View File
@@ -48,7 +48,7 @@ export async function validateSetupToken(
return response<ValidateSetupTokenResponse>(res, {
data: {
valid: false,
message: "Invalid or expired setup token"
message: "Invalid setup token"
},
success: true,
error: false,
+8
View File
@@ -164,6 +164,14 @@ export async function exchangeSession(
)
.limit(1);
if (res) {
if (res.resourceId !== resource.resourceId) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Invalid request token"
)
);
}
await createResourceSession({
token,
resourceId: resource.resourceId,
+4 -1
View File
@@ -19,6 +19,7 @@ Reasons:
106 - Valid email
107 - Valid SSO
108 - Connected Client
109 - Valid Virtual API Key
201 - Resource Not Found
202 - Resource Blocked
@@ -90,7 +91,9 @@ async function flushAuditLogs() {
auditLogBuffer.unshift(...logsToWrite);
logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`);
} else {
logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`);
logger.error(
`Buffer full, dropped ${logsToWrite.length} audit logs`
);
}
} finally {
isFlushInProgress = false;
@@ -236,6 +236,38 @@ function runTests() {
"Root path should not match non-root path"
);
// Path traversal / encoded-slash bypass regression tests
assertEquals(
isPathAllowed("public/*", "public/../admin"),
false,
"Literal .. traversal out of an allowed prefix must not match"
);
assertEquals(
isPathAllowed("public/*", "public%2F..%2Fadmin"),
false,
"Encoded-slash traversal out of an allowed prefix must not match"
);
assertEquals(
isPathAllowed("public/*", "public%2f..%2fadmin%2ffile"),
false,
"Encoded-slash traversal is case-insensitively decoded before matching"
);
assertEquals(
isPathAllowed("admin/*", "public/../admin/secret"),
true,
".. traversal INTO a restricted path should still be caught by its own rule"
);
assertEquals(
isPathAllowed("public/*", "public%2Ffoo"),
true,
"Encoded slash without traversal should still resolve and match normally"
);
assertEquals(
isPathAllowed("public/*", "public/./foo"),
true,
"Single-dot segments are a no-op and should not affect matching"
);
console.log("All path matching tests passed!");
}
+525 -70
View File
@@ -1,18 +1,29 @@
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { generateSessionToken } from "@server/auth/sessions/app";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import {
extractVirtualApiKeyCredential,
verifyVirtualApiKey
} from "@server/auth/verifyVirtualApiKey";
import {
getResourceByDomain,
getResourceRules,
getRoleResourceAccess,
getUserResourceAccess,
getOrgLoginPage,
getUserSessionWithUser
getUserSessionWithUser,
getWhitelistEmail
} from "@server/db/queries/verifySessionQueries";
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
import {
LoginPage,
Org,
Resource,
ResourceAccessToken,
ResourceHeaderAuth,
ResourceHeaderAuthExtendedCompatibility,
ResourcePassword,
@@ -21,7 +32,10 @@ import {
ResourcePolicyPassword,
ResourcePolicyHeaderAuth,
ResourceRule,
ResourceSession
ResourceSession,
db,
resourceAccessToken,
users
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
@@ -35,17 +49,24 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import { getCountryCodeForIp } from "@server/lib/geoip";
import { getAsnForIp } from "@server/lib/asn";
import {
buildInferenceAuthClientError,
type ClientErrorResponse
} from "@server/lib/aiGatewayAuthError";
import { resolveAiCapabilityFromPath } from "@server/lib/aiCapabilities";
import { verifyPassword } from "@server/auth/password";
import {
checkOrgAccessPolicy,
enforceResourceSessionLength
} from "#dynamic/lib/checkOrgAccessPolicy";
import { logRequestAudit } from "./logRequestAudit";
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
import { REGIONS } from "@server/db/regions";
import { localCache } from "#dynamic/lib/cache";
import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { eq } from "drizzle-orm";
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string(), z.string()).optional(),
@@ -74,6 +95,15 @@ type BasicUserData = {
role: string | null;
};
// Some auth methods (e.g. email whitelist) only know the remote email and
// have no associated user record to attach userId/username/name/role to.
type EmailOnlyUserData = {
dontStripSession?: boolean;
email: string;
};
export type { ClientErrorResponse };
export type VerifyUserResponse = {
valid: boolean;
headerAuthChallenged?: boolean;
@@ -81,8 +111,31 @@ export type VerifyUserResponse = {
userData?: BasicUserData;
pangolinVersion?: string;
dontStripSession?: boolean;
clientError?: ClientErrorResponse;
// Set independently of userData so a manual virtual API key with no
// associated user still gets attributed to the key that authenticated
// the request (see the mode === "inference" branch below).
virtualApiKeyId?: string;
};
function notAllowedWithClientError(
res: Response,
clientError: ClientErrorResponse
) {
const data = {
data: {
valid: false,
clientError,
pangolinVersion: APP_VERSION
},
success: true,
error: false,
message: "Access denied",
status: HttpCode.OK
};
return response<VerifyUserResponse>(res, data);
}
export async function verifyResourceSession(
req: Request,
res: Response,
@@ -116,6 +169,10 @@ export async function verifyResourceSession(
// Extract HTTP Basic Auth credentials if present
const clientHeaderAuth = extractBasicAuth(headers);
const clientUserAgent =
headers?.["user-agent"] || headers?.["User-Agent"];
const clientIsBrowser = isBrowserUserAgent(clientUserAgent);
const clientIp = requestIp
? stripPortFromHost(requestIp, badgerVersion)
: undefined;
@@ -208,7 +265,9 @@ export async function verifyResourceSession(
}
const { blockAccess, mode } = resource;
const dontStripSession = ["ssh", "rdp", "vnc"].includes(mode);
const dontStripSession = ["ssh", "rdp", "vnc", "inference"].includes(
mode
);
if (blockAccess) {
logger.debug("Resource blocked", host);
@@ -286,12 +345,105 @@ export async function verifyResourceSession(
!emailWhitelistEnabled &&
!headerAuth
) {
logger.debug("Resource allowed because no auth");
// Public inference always requires a virtual API key.
if (mode !== "inference") {
logger.debug("Resource allowed because no auth");
logRequestAudit(
{
action: true,
reason: 101, // allowed no auth
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
},
parsedBody.data
);
return allowed(res, undefined, dontStripSession);
}
}
// Only offer a browser redirect to clients that can actually follow one and log in
// (an interactive browser). Non-browser clients (curl, scripts, bots, etc.) just get
// an unauthorized response from Badger instead of a login redirect URL.
const redirectPath = clientIsBrowser
? `/auth/resource/${encodeURIComponent(
resource.resourceGuid
)}?redirect=${encodeURIComponent(originalRequestURL)}`
: undefined;
// Virtual API keys for public inference resources (provider-style auth headers).
// Session/SSO may authenticate users elsewhere (e.g. dashboard key pages), but
// only a valid virtual API key is allowed through to the AI gateway.
if (mode === "inference") {
const vakCredential = extractVirtualApiKeyCredential(headers);
if (vakCredential) {
const {
valid,
error,
key,
userData: vakUserData
} = await verifyVirtualApiKey({
credential: vakCredential,
resourceId: resource.resourceId,
orgId: resource.orgId
});
if (error) {
logger.debug("Virtual API key invalid: " + error);
}
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Virtual API key is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
);
}
}
if (valid && key) {
logRequestAudit(
{
action: true,
reason: 109, // valid virtual API key
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
...(vakUserData
? {
user: {
username: vakUserData.username,
userId: vakUserData.userId
}
}
: {
apiKey: {
name: key.name,
apiKeyId: key.virtualApiKeyId
}
}),
metadata: {
virtualApiKeyId: key.virtualApiKeyId,
virtualApiKeyKind: key.kind
}
},
parsedBody.data
);
return allowed(
res,
vakUserData,
dontStripSession,
key.virtualApiKeyId
);
}
}
logRequestAudit(
{
action: true,
reason: 101, // allowed no auth
action: false,
reason: 299, // no more auth methods / VAK required
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC
@@ -299,12 +451,17 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res, undefined, dontStripSession);
}
// Browsers go to the resource auth / API key page. API clients get
// a capability-shaped JSON auth error instead of a redirect.
if (clientIsBrowser) {
return notAllowed(res, redirectPath, resource.orgId);
}
const redirectPath = `/auth/resource/${encodeURIComponent(
resource.resourceGuid
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
return notAllowedWithClientError(
res,
buildInferenceAuthClientError(resolveAiCapabilityFromPath(path))
);
}
// check for access token in headers
if (
@@ -350,22 +507,15 @@ export async function verifyResourceSession(
}
if (valid && tokenItem) {
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
return await allowAccessToken(
res,
resource,
tokenItem,
sessions,
dontStripSession,
parsedBody.data,
ipCC
);
return allowed(res, undefined, dontStripSession);
}
}
@@ -401,27 +551,20 @@ export async function verifyResourceSession(
}
if (valid && tokenItem) {
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
return await allowAccessToken(
res,
resource,
tokenItem,
sessions,
dontStripSession,
parsedBody.data,
ipCC
);
return allowed(res, undefined, dontStripSession);
}
}
// check for HTTP Basic Auth header
const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`;
const clientHeaderAuthKey = `headerAuth:${resource.resourceId}:${clientHeaderAuth}`;
if (headerAuth && clientHeaderAuth) {
if (localCache.get(clientHeaderAuthKey)) {
logger.debug(
@@ -647,6 +790,18 @@ export async function verifyResourceSession(
"Resource allowed because whitelist session is valid"
);
const whitelistCacheKey = `whitelistEmail:${resourceSession.whitelistId}:${resourceSession.policyWhitelistId}`;
let whitelistEmail: string | null | undefined =
localCache.get(whitelistCacheKey);
if (whitelistEmail === undefined) {
whitelistEmail = await getWhitelistEmail(
resourceSession.whitelistId,
resourceSession.policyWhitelistId
);
localCache.set(whitelistCacheKey, whitelistEmail, 12);
}
logRequestAudit(
{
action: true,
@@ -658,30 +813,56 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res, undefined, dontStripSession);
return allowed(
res,
whitelistEmail ? { email: whitelistEmail } : undefined,
dontStripSession
);
}
if (resourceSession.accessTokenId) {
const [tokenItem] = await db
.select()
.from(resourceAccessToken)
.where(
eq(
resourceAccessToken.accessTokenId,
resourceSession.accessTokenId
)
)
.limit(1);
if (
tokenItem &&
tokenItem.resourceId === resource.resourceId
) {
logger.debug(
"Resource allowed because access token session is valid"
);
const userData = await getAccessTokenUserData(
tokenItem,
resource.orgId
);
logAccessTokenRequestAudit(
{
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
accessTokenId: resourceSession.accessTokenId,
tokenTitle: tokenItem.title ?? null,
userData
},
parsedBody.data
);
return allowed(res, userData, dontStripSession);
}
logger.debug(
"Resource allowed because access token session is valid"
"Access token session does not belong to this resource"
);
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId,
orgId: resource.orgId,
location: ipCC,
apiKey: {
name: null,
apiKeyId: resourceSession.accessTokenId
}
},
parsedBody.data
);
return allowed(res, undefined, dontStripSession);
}
if (resourceSession.userSessionId && sso) {
@@ -736,11 +917,14 @@ export async function verifyResourceSession(
}
}
// If headerAuthExtendedCompatibility is activated but no clientHeaderAuth provided, force client to challenge
// If headerAuthExtendedCompatibility is activated but no clientHeaderAuth provided, force client to challenge.
// Skip the challenge when SSO is also enabled so browsers get the SSO redirect instead of a native Basic
// Auth dialog; clients that proactively send Authorization: Basic are still accepted above.
if (
headerAuthExtendedCompatibility &&
headerAuthExtendedCompatibility.extendedCompatibilityIsActivated &&
!clientHeaderAuth
!clientHeaderAuth &&
!sso
) {
return headerAuthChallenged(res, redirectPath, resource.orgId);
}
@@ -869,17 +1053,21 @@ async function notAllowed(
function allowed(
res: Response,
userData?: BasicUserData,
dontStripSession?: boolean
userData?: BasicUserData | EmailOnlyUserData,
dontStripSession?: boolean,
virtualApiKeyId?: string
) {
const baseData =
userData !== undefined && userData !== null
? { valid: true, ...userData, pangolinVersion: APP_VERSION }
: { valid: true, pangolinVersion: APP_VERSION };
const withVirtualApiKey = virtualApiKeyId
? { ...baseData, virtualApiKeyId }
: baseData;
const data = {
data: dontStripSession
? { ...baseData, dontStripSession: true }
: baseData,
? { ...withVirtualApiKey, dontStripSession: true }
: withVirtualApiKey,
success: true,
error: false,
message: "Access allowed",
@@ -889,6 +1077,227 @@ function allowed(
return response<VerifyUserResponse>(res, data);
}
async function allowAccessToken(
res: Response,
resource: Resource,
tokenItem: ResourceAccessToken,
sessions: Record<string, string> | undefined,
dontStripSession: boolean | undefined,
auditBody: VerifyResourceSessionSchema,
location?: string
) {
const userData = await getAccessTokenUserData(tokenItem, resource.orgId);
logAccessTokenRequestAudit(
{
resourceId: resource.resourceId,
orgId: resource.orgId,
location,
accessTokenId: tokenItem.accessTokenId,
tokenTitle: tokenItem.title,
userData
},
auditBody
);
if (!tokenItem.persistSession) {
logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody);
return allowed(res, userData, dontStripSession);
}
const resourceSessionToken = extractResourceSessionToken(
sessions ?? {},
resource.ssl
);
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
resourceSession = result?.resourceSession;
localCache.set(sessionCacheKey, resourceSession, 5);
}
if (
resourceSession &&
!resourceSession.isRequestToken &&
resourceSession.accessTokenId === tokenItem.accessTokenId
) {
logger.debug(
"Resource allowed because existing access token session is valid"
);
return allowed(res, userData, dontStripSession);
}
}
logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody);
return await createAccessTokenSession(res, resource, tokenItem, userData);
}
async function createAccessTokenSession(
res: Response,
resource: Resource,
tokenItem: ResourceAccessToken,
userData?: BasicUserData
) {
const token = generateSessionToken();
const sess = await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = config.getRawConfig().server.session_cookie_name;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain!,
token,
!resource.ssl,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session");
return allowed(res, userData);
}
async function getAccessTokenUserData(
tokenItem: ResourceAccessToken,
orgId: string
): Promise<BasicUserData | undefined> {
if (!tokenItem.userId) {
return undefined;
}
const cacheKey = `accessTokenUser:${tokenItem.userId}:${orgId}`;
const cached = localCache.get(cacheKey) as BasicUserData | null | undefined;
if (cached !== undefined) {
return cached ?? undefined;
}
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, tokenItem.userId))
.limit(1);
if (!user) {
localCache.set(cacheKey, null, 5);
return undefined;
}
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
const userData: BasicUserData = {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
};
localCache.set(cacheKey, userData, 12);
return userData;
}
function logAccessTokenRequestAudit(
data: {
resourceId: number;
orgId: string;
location?: string;
accessTokenId: string;
tokenTitle: string | null;
userData?: BasicUserData;
},
body: VerifyResourceSessionSchema
) {
if (data.userData) {
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: data.resourceId,
orgId: data.orgId,
location: data.location,
user: {
username: data.userData.username,
userId: data.userData.userId
},
metadata: {
accessTokenId: data.accessTokenId,
accessTokenTitle: data.tokenTitle
}
},
body
);
return;
}
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: data.resourceId,
orgId: data.orgId,
location: data.location,
apiKey: {
name: data.tokenTitle,
apiKeyId: data.accessTokenId
}
},
body
);
}
function logAccessTokenAccessAudit(
tokenItem: ResourceAccessToken,
resource: Resource,
userData: BasicUserData | undefined,
body: VerifyResourceSessionSchema
) {
const userAgent =
body.headers?.["user-agent"] || body.headers?.["User-Agent"];
if (userData) {
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: true,
type: "accessToken",
user: {
username: userData.username,
userId: userData.userId
},
metadata: {
accessTokenId: tokenItem.accessTokenId,
accessTokenTitle: tokenItem.title
},
userAgent,
requestIp: body.requestIp
});
return;
}
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: true,
type: "accessToken",
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
},
userAgent,
requestIp: body.requestIp
});
}
async function headerAuthChallenged(
res: Response,
redirectPath?: string,
@@ -1054,7 +1463,10 @@ async function checkRules(
isPathAllowed(rule.value, path)
) {
return rule.action as any;
} else if (clientIp && rule.match == "COUNTRY") {
} else if (
clientIp &&
(rule.match === "COUNTRY" || rule.match === "COUNTRY_IS_NOT")
) {
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
if (
rule.value.toUpperCase() === "ALL" &&
@@ -1063,7 +1475,10 @@ async function checkRules(
continue;
}
if (await isIpInGeoIP(ipCC, rule.value)) {
const inCountry = await isIpInGeoIP(ipCC, rule.value);
const matched = rule.match === "COUNTRY" ? inCountry : !inCountry;
if (matched) {
return rule.action as any;
}
} else if (clientIp && rule.match == "ASN") {
@@ -1234,6 +1649,46 @@ async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
return cachedCountryCode;
}
// Permissive by default: only reject known non-browser clients or a missing
// User-Agent (real browsers always send one). This avoids blocking real
// browsers whose UA string doesn't match a hardcoded allow-list.
const NON_BROWSER_USER_AGENT_PATTERNS = [
/curl/,
/wget/,
/python-requests/,
/python-urllib/,
/go-http-client/,
/okhttp/,
/axios/,
/node-fetch/,
/postmanruntime/,
/insomnia/,
/libwww-perl/,
/java\//,
/ruby/,
/php/,
/bot/,
/spider/,
/crawler/,
/headlesschrome/,
/phantomjs/,
/httpclient/,
/prometheus/,
/go-resty/,
/apache-httpclient/,
/scrapy/
];
function isBrowserUserAgent(userAgent: string | undefined): boolean {
if (!userAgent) {
return false;
}
const ua = userAgent.toLowerCase();
return !NON_BROWSER_USER_AGENT_PATTERNS.some((pattern) => pattern.test(ua));
}
function extractBasicAuth(
headers: Record<string, string> | undefined
): string | undefined {
@@ -0,0 +1,102 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, targets } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
const getBrowserTargetSchema = z
.object({
fullDomain: z.string().min(1, "fullDomain is required")
})
.strict();
export async function getBrowserTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = getBrowserTargetSchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { fullDomain } = parsed.data;
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
const [row] = await db
.select({
ip: targets.ip,
port: targets.port,
authToken: targets.authToken,
resourceId: resources.resourceId,
niceId: resources.niceId,
name: resources.name,
orgId: resources.orgId,
pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(
and(
eq(resources.fullDomain, fullDomain),
eq(targets.enabled, true),
inArray(targets.mode, ["ssh", "rdp", "vnc"])
)
)
.limit(1);
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No resource found for this domain"
)
);
}
const decryptedAuthToken = row.authToken
? decrypt(row.authToken, config.getRawConfig().server.secret!)
: "";
return response<GetBrowserTargetResponse>(res, {
data: {
ip: row.ip,
port: row.port,
authToken: decryptedAuthToken,
pamMode: row.pamMode,
authDaemonMode: row.authDaemonMode,
orgId: row.orgId,
resourceId: row.resourceId,
niceId: row.niceId,
name: row.name ?? ""
},
success: true,
error: false,
message: "Browser target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while retrieving the browser target"
)
);
}
}
@@ -1 +1,2 @@
export * from "./types";
export * from "./getBrowserTarget";
@@ -1,9 +1,106 @@
import { db, Transaction } from "@server/db";
import { Certificate, certificates, db, domains } from "@server/db";
import logger from "@server/logger";
import { Transaction } from "@server/db";
import { eq, or, and, like } from "drizzle-orm";
/**
* Checks if a certificate exists for the given domain.
* If not, creates a new certificate in 'pending' state.
* Wildcard certs cover subdomains.
*/
export async function createCertificate(
domainId: string,
domain: string,
trx: Transaction | typeof db
) {
return;
const [domainRecord] = await trx
.select()
.from(domains)
.where(eq(domains.domainId, domainId))
.limit(1);
if (!domainRecord) {
throw new Error(`Domain with ID ${domainId} not found`);
}
// Note: certificates.domain has a global UNIQUE constraint (it is not
// scoped per-domainId), so existence must be checked by domain value
// alone. Filtering on domainId here as well can cause this check to
// miss an existing cert (e.g. if it was stored under a different but
// still-valid domainId), leading to an INSERT that then fails on the
// unique constraint.
let existing: Certificate[] = [];
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
existing = await trx
.select()
.from(certificates)
.where(
or(
eq(certificates.domain, domain),
and(
eq(certificates.wildcard, true),
or(
eq(certificates.domain, domainLevelDown),
eq(certificates.domain, wildcardPrefixed)
)
)
)
);
} else {
// For non-NS domains, we only match exact domain names
existing = await trx
.select()
.from(certificates)
.where(eq(certificates.domain, domain)); // exact match for non-NS domains
}
if (existing.length > 0) {
logger.info(`Certificate already exists for domain ${domain}`);
return;
}
let domainToWrite = domain;
if (
domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD
domainRecord.preferWildcardCert &&
!domain.startsWith("*.")
) {
// in this case traefik is going to generate a domain one level down so we need to store it that way
const parts = domain.split(".");
if (parts.length > 2) {
domainToWrite = parts.slice(1).join(".");
domainToWrite = `*.${domainToWrite}`;
}
} else if (domainRecord.type == "ns") {
if (domain == domainRecord.baseDomain) {
domainToWrite = domainRecord.baseDomain;
} else {
const parts = domain.split(".");
if (parts.length > 2) {
domainToWrite = parts.slice(1).join(".");
}
}
}
// No cert found, create a new one in pending state. onConflictDoNothing
// guards against the domain having been inserted concurrently (or under
// a different domainId) between the existence check above and this
// insert, since certificates.domain is globally unique.
await trx
.insert(certificates)
.values({
domain: domainToWrite,
domainId,
wildcard:
domainRecord.type == "ns" ||
(domainRecord.type == "wildcard" &&
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
status: "pending",
updatedAt: Math.floor(Date.now() / 1000),
createdAt: Math.floor(Date.now() / 1000)
})
.onConflictDoNothing();
}
@@ -0,0 +1,204 @@
import { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { type GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq, inArray, isNotNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const getCertificateParamSchema = z.strictObject({
orgId: z.string()
});
const getCertificateQuerySchema = z.object({
domains: z.preprocess(
(val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
},
z.array(z.string().min(1).max(255))
)
});
async function query(orgId: string, domainList: string[]) {
// Try to get CNAME certificates first
const existingCertificates = await db
.select({
certId: certificates.certId,
domain: certificates.domain,
wildcard: certificates.wildcard,
status: certificates.status,
expiresAt: certificates.expiresAt,
lastRenewalAttempt: certificates.lastRenewalAttempt,
createdAt: certificates.createdAt,
updatedAt: certificates.updatedAt,
errorMessage: certificates.errorMessage,
renewalCount: certificates.renewalCount,
domainId: domains.domainId,
domainType: domains.type
})
.from(certificates)
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
.leftJoin(
orgDomains,
and(
eq(domains.domainId, orgDomains.domainId),
eq(orgDomains.orgId, orgId)
)
)
.leftJoin(
domainNamespaces,
eq(domains.domainId, domainNamespaces.domainId)
)
.where(
and(
inArray(certificates.domain, domainList),
// Namespace domains are shared across all orgs, so they skip
// the org-ownership check (mirrors verifyCertificateAccess).
or(
isNotNull(orgDomains.orgId),
isNotNull(domainNamespaces.domainNamespaceId)
)
)
);
// All non resolved domain certificates might be `ns` or `wildcard`,
// which means exact domain certificates do not exist
const foundDomains = new Set(
existingCertificates.map((cert) => cert.domain)
);
const domainsWithMissingCertificates = domainList.filter(
(domain) => !foundDomains.has(domain)
);
if (domainsWithMissingCertificates.length > 0) {
const domainLevelDownSet = new Set<string>();
const wildcardDomainSet = new Set<string>();
for (const domain of domainsWithMissingCertificates) {
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
domainLevelDownSet.add(domainLevelDown);
wildcardDomainSet.add(wildcardPrefixed);
}
// Need to map the certificates to each domain
const wildcardCertificates = await db
.select({
certId: certificates.certId,
domain: certificates.domain,
wildcard: certificates.wildcard,
status: certificates.status,
expiresAt: certificates.expiresAt,
lastRenewalAttempt: certificates.lastRenewalAttempt,
createdAt: certificates.createdAt,
updatedAt: certificates.updatedAt,
errorMessage: certificates.errorMessage,
renewalCount: certificates.renewalCount,
domainId: domains.domainId,
domainType: domains.type
})
.from(certificates)
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
.leftJoin(
orgDomains,
and(
eq(domains.domainId, orgDomains.domainId),
eq(orgDomains.orgId, orgId)
)
)
.leftJoin(
domainNamespaces,
eq(domains.domainId, domainNamespaces.domainId)
)
.where(
and(
eq(certificates.wildcard, true),
or(
inArray(certificates.domain, [...domainLevelDownSet]),
inArray(certificates.domain, [...wildcardDomainSet])
),
or(
isNotNull(orgDomains.orgId),
isNotNull(domainNamespaces.domainNamespaceId)
)
)
);
existingCertificates.push(...wildcardCertificates);
}
const certificateMap: Record<string, any> = {};
for (const domain of domainList) {
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
certificateMap[domain] =
existingCertificates.find(
(cert) =>
cert.domain === domain ||
cert.domain === domainLevelDown ||
cert.domain === wildcardPrefixed
) ?? null;
}
return certificateMap;
}
export async function getBatchedCertificates(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getCertificateParamSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedQuery = getCertificateQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { domains } = parsedQuery.data;
const cert = await query(orgId, domains);
return response<GetBatchedCertificateResponse>(res, {
data: cert,
success: true,
error: false,
message: "Certificates retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,163 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { certificates, db, domains } from "@server/db";
import { eq, and, or, like } from "drizzle-orm";
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 { registry } from "@server/openApi";
import { GetCertificateResponse } from "@server/routers/certificates/types";
const getCertificateSchema = z.strictObject({
domainId: z.string(),
domain: z.string().min(1).max(255),
orgId: z.string()
});
async function query(domainId: string, domain: string) {
const [domainRecord] = await db
.select()
.from(domains)
.where(eq(domains.domainId, domainId))
.limit(1);
if (!domainRecord) {
throw new Error(`Domain with ID ${domainId} not found`);
}
const domainType = domainRecord.type;
let existing: any[] = [];
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
existing = await db
.select({
certId: certificates.certId,
domain: certificates.domain,
wildcard: certificates.wildcard,
status: certificates.status,
expiresAt: certificates.expiresAt,
lastRenewalAttempt: certificates.lastRenewalAttempt,
createdAt: certificates.createdAt,
updatedAt: certificates.updatedAt,
errorMessage: certificates.errorMessage,
renewalCount: certificates.renewalCount
})
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
or(
eq(certificates.domain, domain),
and(
eq(certificates.wildcard, true),
or(
eq(certificates.domain, domainLevelDown),
eq(certificates.domain, wildcardPrefixed)
)
)
)
)
);
} else {
// For non-NS domains, we only match exact domain names
existing = await db
.select({
certId: certificates.certId,
domain: certificates.domain,
wildcard: certificates.wildcard,
status: certificates.status,
expiresAt: certificates.expiresAt,
lastRenewalAttempt: certificates.lastRenewalAttempt,
createdAt: certificates.createdAt,
updatedAt: certificates.updatedAt,
errorMessage: certificates.errorMessage,
renewalCount: certificates.renewalCount
})
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
eq(certificates.domain, domain) // exact match for non-NS domains
)
);
}
return existing.length > 0 ? { ...existing[0], domainType } : null;
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/certificate/{domainId}/{domain}",
description: "Get a certificate by domain.",
tags: ["Certificate"],
request: {
params: z.object({
domainId: z.string(),
domain: z.string().min(1).max(255),
orgId: z.string()
})
},
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()
})
}
}
}
}
});
export async function getCertificate(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getCertificateSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { domainId, domain } = parsedParams.data;
const cert = await query(domainId, domain);
if (!cert) {
logger.warn(`Certificate not found for domain: ${domainId}`);
return next(
createHttpError(HttpCode.NOT_FOUND, "Certificate not found")
);
}
return response<GetCertificateResponse>(res, {
data: cert,
success: true,
error: false,
message: "Certificate retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./getCertificate";
export * from "./restartCertificate";
export * from "./syncCertToNewts";
export * from "./getBatchedCertificates";
export * from "./createCertificate";
@@ -0,0 +1,111 @@
import { certificates, db } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const restartCertificateParamsSchema = z.strictObject({
certId: z.coerce.number().int().positive(),
orgId: z.string()
});
registry.registerPath({
method: "post",
path: "/certificate/{certId}",
description: "Restart a certificate by ID.",
tags: ["Certificate"],
request: {
params: z.object({
certId: z.coerce.number().int().positive(),
orgId: z.string()
})
},
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()
})
}
}
}
}
});
export async function restartCertificate(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = restartCertificateParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { certId } = parsedParams.data;
// get the certificate by ID
const [cert] = await db
.select()
.from(certificates)
.where(eq(certificates.certId, certId))
.limit(1);
if (!cert) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Certificate not found")
);
}
if (cert.status != "failed" && cert.status != "expired") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Certificate is already valid, no need to restart"
)
);
}
// update the certificate status to 'pending'
await db
.update(certificates)
.set({
status: "pending",
errorMessage: null,
lastRenewalAttempt: Math.floor(Date.now() / 1000)
})
.where(eq(certificates.certId, certId));
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Certificate restarted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,55 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
const bodySchema = z.object({
domain: z.string().min(1),
domainId: z.string().nullable().optional().default(null)
});
export async function syncCertToNewts(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const parsed = bodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { domain, domainId } = parsed.data;
logger.debug(
`syncCertToNewts: received request to push cert update for domain "${domain}" (domainId: ${domainId ?? "none"})`
);
try {
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
res.status(HttpCode.OK).json({
data: null,
success: true,
error: false,
message: `Certificate update pushed to affected newts for domain "${domain}"`
});
} catch (err) {
logger.error(
`syncCertToNewts: error pushing cert update for domain "${domain}": ${err}`
);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to push certificate update to affected newts"
)
);
}
}
+9 -2
View File
@@ -1,9 +1,11 @@
import type { Domain } from "@server/db";
export type GetCertificateResponse = {
certId: number;
domain: string;
domainId: string;
wildcard: boolean;
domainType: string;
domainType: Domain["type"];
status: string; // pending, requested, valid, expired, failed
expiresAt: string | null;
lastRenewalAttempt: Date | null;
@@ -11,4 +13,9 @@ export type GetCertificateResponse = {
updatedAt: number;
errorMessage?: string | null;
renewalCount: number;
};
};
export type GetBatchedCertificateResponse = Record<
string,
GetCertificateResponse | null
>;
-15
View File
@@ -255,20 +255,6 @@ export async function createClient(
let newClient: Client | null = null;
await db.transaction(async (trx) => {
// TODO: more intelligent way to pick the exit node
const exitNodesList = await listExitNodes(orgId);
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
if (!randomExitNode) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
)
);
}
const [adminRole] = await trx
.select()
.from(roles)
@@ -287,7 +273,6 @@ export async function createClient(
.insert(clients)
.values({
niceId,
exitNodeId: randomExitNode.exitNodeId,
orgId,
name,
subnet: updatedSubnet,
@@ -222,11 +222,6 @@ export async function createUserClient(
let newClient: Client | null = null;
await db.transaction(async (trx) => {
// TODO: more intelligent way to pick the exit node
const exitNodesList = await listExitNodes(orgId);
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
const [adminRole] = await trx
.select()
.from(roles)
@@ -244,7 +239,6 @@ export async function createUserClient(
[newClient] = await trx
.insert(clients)
.values({
exitNodeId: randomExitNode.exitNodeId,
orgId,
niceId,
name,
+14 -24
View File
@@ -304,11 +304,6 @@ export async function listClients(
(client) => client.clientId
);
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
// Get client count with filter
const conditions = [
and(
@@ -341,7 +336,7 @@ export async function listClients(
conditions.push(or(...filterAggregates));
}
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
if (labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(
clients.clientId,
@@ -361,25 +356,20 @@ export async function listClients(
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${clients.name})`, q),
like(sql`LOWER(${clients.niceId})`, q)
like(sql`LOWER(${clients.niceId})`, q),
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
@@ -414,7 +404,7 @@ export async function listClients(
clientId: number;
}> = [];
if (isLabelFeatureEnabled && clientIds.length > 0) {
if (clientIds.length > 0) {
labelsForClients = await db
.select({
labelId: labels.labelId,
+17 -6
View File
@@ -67,12 +67,12 @@ const listUserDevicesSchema = z.strictObject({
}),
query: z.string().optional(),
sort_by: z
.enum(["megabytesIn", "megabytesOut"])
.enum(["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["megabytesIn", "megabytesOut"],
enum: ["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"],
description: "Field to sort by"
}),
order: z
@@ -183,7 +183,9 @@ function queryUserDevicesBase() {
fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname
fingerprintHostname: currentFingerprint.hostname,
firstSeen: currentFingerprint.firstSeen,
lastSeen: currentFingerprint.lastSeen
})
.from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
@@ -389,14 +391,23 @@ export async function listUserDevices(
const countQuery = db.$count(baseQuery.as("filtered_clients"));
const sortColumn =
sort_by === "firstSeen"
? currentFingerprint.firstSeen
: sort_by === "lastSeen"
? currentFingerprint.lastSeen
: sort_by
? clients[sort_by]
: undefined;
const listDevicesQuery = baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
sortColumn
? order === "asc"
? asc(clients[sort_by])
: desc(clients[sort_by])
? asc(sortColumn)
: desc(sortColumn)
: asc(clients.clientId)
);
+26 -1
View File
@@ -33,7 +33,7 @@ const UpdateDomainResponseDataSchema = z.object({
registry.registerPath({
method: "patch",
method: "post",
path: "/org/{orgId}/domain/{domainId}",
description: "Update a domain by domainId.",
tags: [OpenAPITags.Domain],
@@ -55,6 +55,31 @@ registry.registerPath({
}
});
registry.registerPath({
method: "patch",
path: "/org/{orgId}/domain/{domainId}",
description:
"Update a domain by domainId. Deprecated: use POST instead.",
deprecated: true,
tags: [OpenAPITags.Domain],
request: {
params: z.object({
domainId: z.string(),
orgId: z.string()
})
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(UpdateDomainResponseDataSchema)
}
}
}
}
});
export async function updateOrgDomain(
req: Request,
res: Response,
+585 -14
View File
@@ -20,6 +20,7 @@ import * as logs from "./auditLogs";
import * as launcher from "./launcher";
import * as newt from "./newt";
import * as olm from "./olm";
import * as ssh from "./ssh";
import * as serverInfo from "./serverInfo";
import HttpCode from "@server/types/HttpCode";
import {
@@ -45,15 +46,29 @@ import {
verifySiteResourceAccess,
verifyOlmAccess,
verifyLimits,
verifyResourcePolicyAccess
verifyResourcePolicyAccess,
verifyAiProviderAccess,
verifyAiModelAccess,
verifyAiBudgetAccess,
verifyVirtualApiKeyAccess,
logActionAudit,
verifyCertificateAccess
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import createHttpError from "http-errors";
import { build } from "@server/build";
import { createStore } from "#dynamic/lib/rateLimitStore";
import { logActionAudit } from "#dynamic/middlewares";
import { checkRoundTripMessage } from "./ws";
import * as labels from "@server/routers/labels";
import * as aiProvider from "@server/routers/aiProvider";
import * as aiBudget from "@server/routers/aiBudget";
import * as virtualApiKey from "@server/routers/virtualApiKey";
import * as certificates from "@server/routers/certificates";
function rateLimitIdentityKey(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
// Root routes
export const unauthenticated = Router();
@@ -247,6 +262,22 @@ authenticated.post(
site.updateSite
);
authenticated.post(
"/site/:siteId/approve",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.updateSiteApprovals),
logActionAudit(ActionsEnum.updateSiteApprovals),
site.approveSite
);
authenticated.post(
"/site/:siteId/reject",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.updateSiteApprovals),
logActionAudit(ActionsEnum.updateSiteApprovals),
site.rejectSite
);
authenticated.delete(
"/site/:siteId",
verifySiteAccess,
@@ -302,6 +333,13 @@ authenticated.get(
site.getSiteStatusHistory
);
authenticated.get(
"/org/:orgId/site-status-histories",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listSites),
site.getBatchedSiteStatusHistory
);
// Site Resource endpoints
authenticated.put(
"/org/:orgId/site-resource",
@@ -380,6 +418,20 @@ authenticated.get(
siteResource.listSiteResourceClients
);
authenticated.get(
"/site-resource/:siteResourceId/ai-models",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceAiModels),
siteResource.listSiteResourceAiModels
);
authenticated.get(
"/site-resource/:siteResourceId/ai-providers",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceAiModels),
siteResource.listSiteResourceAiProviders
);
authenticated.post(
"/site-resource/:siteResourceId/roles",
verifySiteResourceAccess,
@@ -390,6 +442,54 @@ authenticated.post(
siteResource.setSiteResourceRoles
);
authenticated.post(
"/site-resource/:siteResourceId/ai-models",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.setSiteResourceAiModels
);
authenticated.post(
"/site-resource/:siteResourceId/ai-models/add",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.addAiModelToSiteResource
);
authenticated.post(
"/site-resource/:siteResourceId/ai-models/remove",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.removeAiModelFromSiteResource
);
authenticated.post(
"/site-resource/:siteResourceId/ai-providers",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.setSiteResourceAiProviders
);
authenticated.post(
"/site-resource/:siteResourceId/ai-providers/add",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.addAiProviderToSiteResource
);
authenticated.post(
"/site-resource/:siteResourceId/ai-providers/remove",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
siteResource.removeAiProviderFromSiteResource
);
authenticated.post(
"/site-resource/:siteResourceId/users",
verifySiteResourceAccess,
@@ -452,6 +552,13 @@ authenticated.get(
resource.getResourceStatusHistory
);
authenticated.get(
"/org/:orgId/resource-status-histories",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listResources),
resource.getBatchedResourceStatusHistory
);
authenticated.get(
"/org/:orgId/resources",
verifyOrgAccess,
@@ -490,6 +597,20 @@ authenticated.get(
launcher.listLauncherResources
);
authenticated.get(
"/org/:orgId/launcher/resource/:resourceId/ai-models",
verifyOrgAccess,
verifyResourceAccess,
launcher.listLauncherPublicAiModels
);
authenticated.get(
"/org/:orgId/launcher/site-resource/:siteResourceId/ai-models",
verifyOrgAccess,
verifySiteResourceAccess,
launcher.listLauncherSiteAiModels
);
authenticated.get(
"/org/:orgId/launcher/sites",
verifyOrgAccess,
@@ -617,6 +738,20 @@ authenticated.get(
resource.listResourceUsers
);
authenticated.get(
"/resource/:resourceId/ai-models",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceAiModels),
resource.listResourceAiModels
);
authenticated.get(
"/resource/:resourceId/ai-providers",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceAiModels),
resource.listResourceAiProviders
);
authenticated.get(
"/resource/:resourceId",
verifyResourceAccess,
@@ -820,6 +955,54 @@ authenticated.post(
resource.setResourceUsers
);
authenticated.post(
"/resource/:resourceId/ai-models",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.setResourceAiModels
);
authenticated.post(
"/resource/:resourceId/ai-models/add",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.addAiModelToResource
);
authenticated.post(
"/resource/:resourceId/ai-models/remove",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.removeAiModelFromResource
);
authenticated.post(
"/resource/:resourceId/ai-providers",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.setResourceAiProviders
);
authenticated.post(
"/resource/:resourceId/ai-providers/add",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.addAiProviderToResource
);
authenticated.post(
"/resource/:resourceId/ai-providers/remove",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceAiModels),
logActionAudit(ActionsEnum.setResourceAiModels),
resource.removeAiProviderFromResource
);
authenticated.put(
"/resource-policy/:resourcePolicyId/access-control",
verifyResourcePolicyAccess,
@@ -1311,6 +1494,48 @@ authenticated.get(
logs.exportRequestAuditLogs
);
authenticated.get(
"/org/:orgId/logs/ai/usage/filters",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageFilterOptions
);
authenticated.get(
"/org/:orgId/logs/ai/usage/overview",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageOverview
);
authenticated.get(
"/org/:orgId/logs/ai/usage/providers",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageProviders
);
authenticated.get(
"/org/:orgId/logs/ai/usage/resources",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageResources
);
authenticated.get(
"/org/:orgId/logs/ai/usage/users-roles",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageUsersRoles
);
authenticated.get(
"/org/:orgId/logs/ai/usage/virtual-api-keys",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageVirtualApiKeys
);
authenticated.get(
"/org/:orgId/blueprints",
verifyOrgAccess,
@@ -1335,9 +1560,356 @@ authenticated.get(
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
authenticated.put(
"/org/:orgId/ai-provider",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createAiProvider),
logActionAudit(ActionsEnum.createAiProvider),
aiProvider.createAiProvider
);
authenticated.get(
"/org/:orgId/ai-providers",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listAiProviders),
aiProvider.listAiProviders
);
authenticated.get(
"/ai-provider/:providerId",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.getAiProvider),
aiProvider.getAiProvider
);
authenticated.get(
"/org/:orgId/ai-provider/:niceId",
verifyOrgAccess,
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(
"/ai-provider/:providerId",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.updateAiProvider),
logActionAudit(ActionsEnum.updateAiProvider),
aiProvider.updateAiProvider
);
authenticated.delete(
"/ai-provider/:providerId",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.deleteAiProvider),
logActionAudit(ActionsEnum.deleteAiProvider),
aiProvider.deleteAiProvider
);
authenticated.put(
"/ai-provider/:providerId/model",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.createAiModel),
logActionAudit(ActionsEnum.createAiModel),
aiProvider.createAiModel
);
authenticated.get(
"/ai-provider/:providerId/models",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.listAiModels),
aiProvider.listAiModels
);
authenticated.get(
"/ai-provider/:providerId/catalog-models",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.listAiModels),
aiProvider.listCatalogModels
);
authenticated.get(
"/org/:orgId/ai-catalog-models",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listAiModels),
aiProvider.listCatalogModelsByType
);
authenticated.get(
"/ai-model/:modelId",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.getAiModel),
aiProvider.getAiModel
);
authenticated.post(
"/ai-model/:modelId",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.updateAiModel),
logActionAudit(ActionsEnum.updateAiModel),
aiProvider.updateAiModel
);
authenticated.delete(
"/ai-model/:modelId",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.deleteAiModel),
logActionAudit(ActionsEnum.deleteAiModel),
aiProvider.deleteAiModel
);
authenticated.put(
"/org/:orgId/ai-budget",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createAiBudget),
logActionAudit(ActionsEnum.createAiBudget),
aiBudget.createAiBudget
);
authenticated.get(
"/org/:orgId/ai-budgets",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listAiBudgets),
aiBudget.listAiBudgets
);
authenticated.get(
"/ai-budget/:budgetId",
verifyAiBudgetAccess,
verifyUserHasAction(ActionsEnum.getAiBudget),
aiBudget.getAiBudget
);
authenticated.post(
"/ai-budget/:budgetId",
verifyAiBudgetAccess,
verifyUserHasAction(ActionsEnum.updateAiBudget),
logActionAudit(ActionsEnum.updateAiBudget),
aiBudget.updateAiBudget
);
authenticated.delete(
"/ai-budget/:budgetId",
verifyAiBudgetAccess,
verifyUserHasAction(ActionsEnum.deleteAiBudget),
logActionAudit(ActionsEnum.deleteAiBudget),
aiBudget.deleteAiBudget
);
authenticated.put(
"/org/:orgId/virtual-api-key",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createVirtualApiKey),
logActionAudit(ActionsEnum.createVirtualApiKey),
virtualApiKey.createVirtualApiKey
);
authenticated.get(
"/org/:orgId/virtual-api-keys",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listVirtualApiKeys),
virtualApiKey.listVirtualApiKeys
);
authenticated.post(
"/org/:orgId/virtual-api-keys/email-identity-keys",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getVirtualApiKey),
virtualApiKey.emailIdentityKeysRateLimit,
logActionAudit(ActionsEnum.getVirtualApiKey),
virtualApiKey.emailIdentityKeys
);
authenticated.get(
"/org/:orgId/my-virtual-api-keys",
verifyOrgAccess,
virtualApiKey.listMyVirtualApiKeys
);
authenticated.get(
"/org/:orgId/my-virtual-api-keys/:virtualApiKeyId",
verifyOrgAccess,
virtualApiKey.getMyVirtualApiKey
);
authenticated.get(
"/virtual-api-key/:virtualApiKeyId",
verifyVirtualApiKeyAccess,
verifyUserHasAction(ActionsEnum.getVirtualApiKey),
virtualApiKey.getVirtualApiKey
);
authenticated.post(
"/virtual-api-key/:virtualApiKeyId",
verifyVirtualApiKeyAccess,
verifyUserHasAction(ActionsEnum.updateVirtualApiKey),
logActionAudit(ActionsEnum.updateVirtualApiKey),
virtualApiKey.updateVirtualApiKey
);
authenticated.delete(
"/virtual-api-key/:virtualApiKeyId",
verifyVirtualApiKeyAccess,
verifyUserHasAction(ActionsEnum.deleteVirtualApiKey),
logActionAudit(ActionsEnum.deleteVirtualApiKey),
virtualApiKey.deleteVirtualApiKey
);
authenticated.get(
"/ai-provider/:providerId/ai-budgets",
verifyAiProviderAccess,
verifyUserHasAction(ActionsEnum.listAiBudgets),
aiBudget.listAiBudgetsForProvider
);
authenticated.get(
"/ai-model/:modelId/ai-budgets",
verifyAiModelAccess,
verifyUserHasAction(ActionsEnum.listAiBudgets),
aiBudget.listAiBudgetsForModel
);
authenticated.get(
"/resource/:resourceId/ai-budgets",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.listAiBudgets),
aiBudget.listAiBudgetsForResource
);
authenticated.get(
"/site-resource/:siteResourceId/ai-budgets",
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.listAiBudgets),
aiBudget.listAiBudgetsForSiteResource
);
authenticated.get(
"/role/:roleId/ai-budgets",
verifyRoleAccess,
verifyUserHasAction(ActionsEnum.listAiBudgets),
aiBudget.listAiBudgetsForRole
);
authenticated.get(
"/virtual-api-key/:virtualApiKeyId/ai-budgets",
verifyVirtualApiKeyAccess,
verifyUserHasAction(ActionsEnum.listAiBudgets),
aiBudget.listAiBudgetsForVirtualApiKey
);
authenticated.get(
"/org/:orgId/labels",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
authenticated.post(
"/org/:orgId/ssh/sign-key",
verifyOrgAccess,
verifyLimits,
// verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
ssh.signSshKey
);
authenticated.get(
"/client/:clientId/verify-associations-cache",
verifyClientAccess,
client.verifyClientAssociationsCache
);
authenticated.post(
"/client/:clientId/rebuild-associations-cache",
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);
authenticated.get(
"/org/:orgId/certificate/:domainId/:domain",
verifyOrgAccess,
verifyCertificateAccess,
verifyUserHasAction(ActionsEnum.getCertificate),
certificates.getCertificate
);
authenticated.get(
"/org/:orgId/batched-certificates",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getCertificate),
certificates.getBatchedCertificates
);
authenticated.post(
"/org/:orgId/certificate/:certId/restart",
verifyOrgAccess,
verifyCertificateAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.restartCertificate),
logActionAudit(ActionsEnum.restartCertificate),
certificates.restartCertificate
);
// Auth routes
export const authRouter = Router();
unauthenticated.use("/auth", authRouter);
// Register setup-check BEFORE the global auth rate limiter.
// This endpoint is called on every dashboard root page load (pure boolean
// read, no secrets) and must not consume the auth rate-limit budget.
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
authRouter.use(
rateLimit({
windowMs:
@@ -1359,7 +1931,7 @@ authRouter.put(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`signup:${ipKeyGenerator(req.ip || "")}:${req.body.email}`,
`signup:${ipKeyGenerator(req.ip || "")}:${rateLimitIdentityKey(req.body.email)}`,
handler: (req, res, next) => {
const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1374,7 +1946,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`login:${req.body.email || ipKeyGenerator(req.ip || "")}`,
`login:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only log in ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1391,7 +1963,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`lookupUser:${req.body.identifier || ipKeyGenerator(req.ip || "")}`,
`lookupUser:${rateLimitIdentityKey(req.body.identifier) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only lookup users ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1469,7 +2041,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) => {
return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
return `signup:${rateLimitIdentityKey(req.body.email) || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
},
handler: (req, res, next) => {
const message = `You can only enable 2FA ${15} times every ${15} minutes. Please try again later.`;
@@ -1485,7 +2057,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) => {
return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
return `signup:${rateLimitIdentityKey(req.body.email) || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
},
handler: (req, res, next) => {
const message = `You can only request a 2FA code ${15} times every ${15} minutes. Please try again later.`;
@@ -1517,7 +2089,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`signup:${req.body.email || ipKeyGenerator(req.ip || "")}`,
`signup:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1535,7 +2107,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`,
`requestEmailVerificationCode:${rateLimitIdentityKey(req.user?.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1557,7 +2129,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`requestPasswordReset:${req.body.email || ipKeyGenerator(req.ip || "")}`,
`requestPasswordReset:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1573,7 +2145,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`resetPassword:${req.body.email || ipKeyGenerator(req.ip || "")}`,
`resetPassword:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1620,7 +2192,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`authWithWhitelist:${ipKeyGenerator(req.ip || "")}:${req.body.email}:${req.params.resourceId}`,
`authWithWhitelist:${ipKeyGenerator(req.ip || "")}:${rateLimitIdentityKey(req.body.email)}:${req.params.resourceId}`,
handler: (req, res, next) => {
const message = `You can only request an email OTP ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1642,7 +2214,6 @@ authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl);
authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
authRouter.put("/set-server-admin", auth.setServerAdmin);
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
authRouter.post("/validate-setup-token", auth.validateSetupToken);
// Security Key routes
@@ -1673,7 +2244,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
keyGenerator: (req) => {
return `securityKeyAuth:${req.body.email || ipKeyGenerator(req.ip || "")}`;
return `securityKeyAuth:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`;
},
handler: (req, res, next) => {
const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`;
+1 -7
View File
@@ -15,13 +15,7 @@ export async function createExitNode(
if (!exitNodeQuery) {
const { value: address, release } = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
@@ -32,7 +26,7 @@ export async function createExitNode(
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
endpoint: config.getRawConfig().gerbil.base_endpoint,
address,
online: true,
listenPort,
+4 -4
View File
@@ -100,7 +100,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
// Filter to sites with the required fields up front so the rest of the
// function can safely treat endpoint/subnet/listenPort as defined.
const validSites = sitesRes.filter(
(s) => s.endpoint && s.subnet && s.listenPort
(s) => s.endpoint && s.exitNodeSubnet && s.listenPort
);
if (validSites.length === 0) {
@@ -136,7 +136,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
if (
peer.orgId == null ||
!peer.endpoint ||
!peer.subnet ||
!peer.exitNodeSubnet ||
!peer.listenPort
) {
continue;
@@ -183,7 +183,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
// Process each site using the pre-fetched data.
for (const site of validSites) {
const siteDestination: PeerDestination = {
destinationIP: site.subnet!.split("/")[0],
destinationIP: site.exitNodeSubnet!.split("/")[0],
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
};
@@ -207,7 +207,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
continue;
}
addDestination(site.endpoint!, {
destinationIP: peer.subnet!.split("/")[0],
destinationIP: peer.exitNodeSubnet!.split("/")[0],
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
});
}
+29 -4
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { sites, exitNodes, ExitNode } from "@server/db";
import { sites, exitNodes, ExitNode, clients } from "@server/db";
import { db } from "@server/db";
import { eq, isNotNull, and } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
@@ -89,11 +89,27 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
and(
eq(sites.exitNodeId, exitNode.exitNodeId),
isNotNull(sites.pubKey),
isNotNull(sites.subnet)
isNotNull(sites.exitNodeSubnet)
)
);
const peers = await Promise.all(
const clientsRes = await db
.select()
.from(clients)
.where(
and(
eq(clients.exitNodeId, exitNode.exitNodeId),
isNotNull(clients.pubKey),
isNotNull(clients.exitNodeSubnet)
)
);
let peers: {
publicKey: string | null;
allowedIps: string[];
}[] = [];
const sitePeers = await Promise.all(
sitesRes.map(async (site) => {
if (site.type === "wireguard") {
return {
@@ -103,7 +119,7 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
} else if (site.type === "newt") {
return {
publicKey: site.pubKey,
allowedIps: [site.subnet!]
allowedIps: [site.exitNodeSubnet!]
};
}
return {
@@ -113,6 +129,15 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
})
);
const clientPeers = clientsRes.map((client) => {
return {
publicKey: client.pubKey,
allowedIps: [client.exitNodeSubnet!]
};
});
peers = [...sitePeers, ...clientPeers];
const configResponse: GetConfigResponse = {
listenPort: exitNode.listenPort || 51820,
ipAddress: exitNode.address,
+4 -2
View File
@@ -112,7 +112,9 @@ export async function updateHolePunch(
destinations: destinations
});
} catch (error) {
logger.error(error);
if (!(error instanceof Error && error.message === "Exit node not allowed")) {
logger.error(error);
}
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
@@ -186,7 +188,7 @@ export async function updateAndGenerateEndpointDestinations(
.select({
siteId: sites.siteId,
newtId: newts.newtId,
subnet: sites.subnet,
subnet: sites.exitNodeSubnet,
listenPort: sites.listenPort,
publicKey: sites.publicKey,
endpoint: clientSitesAssociationsCache.endpoint,
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -1,16 +1,20 @@
import { Router } from "express";
import * as gerbil from "@server/routers/gerbil";
import * as traefik from "@server/routers/traefik";
import * as resource from "./resource";
import * as badger from "./badger";
import * as resource from "@server/routers/resource";
import * as badger from "@server/routers/badger";
import * as auth from "@server/routers/auth";
import * as supporterKey from "@server/routers/supporterKey";
import * as idp from "@server/routers/idp";
import * as ssh from "@server/routers/ssh";
import HttpCode from "@server/types/HttpCode";
import {
verifyResourceAccess,
verifySessionUserMiddleware
verifySessionUserMiddleware,
verifyUserFromResourceSessionMiddleware
} from "@server/middlewares";
import * as ws from "@server/routers/ws";
import * as browserTarget from "@server/routers/browserGatewayTarget";
// Root routes
export const internalRouter = Router();
@@ -42,6 +46,12 @@ internalRouter.get("/idp", idp.listIdps);
internalRouter.get("/idp/:idpId", idp.getIdp);
internalRouter.post(
"/org/:orgId/ssh/sign-key",
verifyUserFromResourceSessionMiddleware,
ssh.signSshKey
);
// Gerbil routes
const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter);
@@ -63,3 +73,11 @@ internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession);
internalRouter.get("/resource/browser-target", browserTarget.getBrowserTarget);
internalRouter.get(
"/ws/round-trip-message/:messageId",
verifyUserFromResourceSessionMiddleware,
ws.checkRoundTripMessage
);
+211
View File
@@ -0,0 +1,211 @@
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const attachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function attachLabelToItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = attachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteLabels)
.values({
labelId,
siteId
})
.onConflictDoNothing();
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(resourceLabels)
.values({
labelId,
resourceId
})
.onConflictDoNothing();
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteResourceLabels)
.values({
labelId,
siteResourceId
})
.onConflictDoNothing();
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(clientLabels)
.values({
labelId,
clientId
})
.onConflictDoNothing();
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label attached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+157
View File
@@ -0,0 +1,157 @@
import {
db,
labels,
resourceLabels,
resources,
siteLabels,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z.strictObject({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty(),
siteId: z.number().int().optional(),
resourceId: z.number().int().optional()
});
export async function createOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { name, color, siteId, resourceId } = parsedBody.data;
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Site with Id ${siteId} doesn't exist.`
)
);
}
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
}
const [existingLabel] = await db
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
)
)
.limit(1);
if (existingLabel) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A label with this name already exists"
)
);
}
const label = await db.transaction(async (tx) => {
const [label] = await tx
.insert(labels)
.values({
name,
color,
orgId
})
.returning();
if (siteId) {
await tx.insert(siteLabels).values({
siteId,
labelId: label.labelId
});
}
if (resourceId) {
await tx.insert(resourceLabels).values({
resourceId,
labelId: label.labelId
});
}
return label;
});
return response<CreateOrEditLabelResponse>(res, {
data: { label },
success: true,
error: false,
message: "Org Label created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
export async function deleteOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
await db
.delete(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
return response(res, {
data: null,
success: true,
error: false,
message: "Label deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,211 @@
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const detachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function detachLabelFromItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = detachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
await db
.delete(siteLabels)
.where(
and(
eq(siteLabels.labelId, labelId),
eq(siteLabels.siteId, siteId)
)
);
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
await db
.delete(resourceLabels)
.where(
and(
eq(resourceLabels.labelId, labelId),
eq(resourceLabels.resourceId, resourceId)
)
);
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
await db
.delete(siteResourceLabels)
.where(
and(
eq(siteResourceLabels.labelId, labelId),
eq(siteResourceLabels.siteResourceId, siteResourceId)
)
);
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
await db
.delete(clientLabels)
.where(
and(
eq(clientLabels.labelId, labelId),
eq(clientLabels.clientId, clientId)
)
);
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label detached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./listOrgLabels";
export * from "./createOrgLabel";
export * from "./updateOrgLabel";
export * from "./attachLabelToItem";
export * from "./detachLabelFromItem";
export * from "./deleteOrgLabel";
+142
View File
@@ -0,0 +1,142 @@
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, asc, eq, like, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listLabelsSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
function queryLabelsBase() {
return db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color
})
.from(labels);
}
export async function listOrgLabels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listLabelsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [and(eq(labels.orgId, orgId))];
if (query) {
conditions.push(
like(
sql`LOWER(${labels.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = queryLabelsBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
queryLabelsBase()
.where(and(...conditions))
.as("filtered_labels")
);
const labelListQuery = baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(labels.name));
const [totalCount, rows] = await Promise.all([
countQuery,
labelListQuery
]);
return response<ListOrgLabelsResponse>(res, {
data: {
labels: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Labels retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq, ne, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const updateLabelBodySchema = z.strictObject({
name: z.string().min(1).max(255).optional(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export async function updateOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = updateLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
const { name, color } = parsedBody.data;
if (name && name.toLowerCase() !== existing.name.toLowerCase()) {
const [duplicateLabel] = await db
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
ne(labels.labelId, labelId),
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
)
)
.limit(1);
if (duplicateLabel) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A label with this name already exists"
)
);
}
}
const [label] = await db
.update(labels)
.set({
name,
color
})
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)))
.returning();
return response<CreateOrEditLabelResponse>(res, {
data: {
label
},
success: true,
error: false,
message: "Label updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+15 -11
View File
@@ -1,7 +1,8 @@
import { SiteResource } from "@server/db";
import { formatEndpoint, parseEndpoint } from "@server/lib/ip";
export type SiteResourceDestinationInput = {
mode: "host" | "cidr" | "http" | "ssh";
mode: SiteResource["mode"];
destination: string | null;
destinationPort: number | null;
scheme: "http" | "https" | null;
@@ -97,7 +98,7 @@ function formatTcpUdpResourceAccess(
export function formatPublicResourceAccess(
resource: PublicResourceAccessInput
): LauncherAccessFields {
const browserModes = ["http", "ssh", "rdp", "vnc"];
const browserModes = ["http", "ssh", "rdp", "vnc", "inference"];
if (!browserModes.includes(resource.mode)) {
return formatTcpUdpResourceAccess(
resource.exitNodeEndpoint,
@@ -124,15 +125,10 @@ export function formatPublicResourceAccess(
export function formatSiteResourceAccess(
resource: SiteResourceAccessInput
): LauncherAccessFields {
if (resource.alias) {
return {
accessDisplay: resource.alias,
accessCopyValue: resource.alias,
accessUrl: null
};
}
if (resource.mode === "http" && resource.fullDomain) {
if (
(resource.mode === "http" || resource.mode === "inference") &&
resource.fullDomain
) {
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return {
accessDisplay: url,
@@ -141,6 +137,14 @@ export function formatSiteResourceAccess(
};
}
if (resource.alias) {
return {
accessDisplay: resource.alias,
accessCopyValue: resource.alias,
accessUrl: null
};
}
const destination = formatSiteResourceDestinationDisplay({
mode: resource.mode as SiteResourceDestinationInput["mode"],
destination: resource.destination,
+5
View File
@@ -5,6 +5,11 @@ export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels";
export { listLauncherViews } from "./listLauncherViews";
export {
listLauncherPublicAiModels,
listLauncherSiteAiModels
} from "./listLauncherAiModels";
export type { ListLauncherAiModelsResponse } from "./listLauncherAiModels";
export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView";
+197 -138
View File
@@ -31,6 +31,7 @@ import {
inArray,
isNull,
like,
ne,
or,
sql,
type SQL
@@ -40,6 +41,7 @@ import {
formatSiteResourceAccess
} from "./formatLauncherAccess";
import {
LAUNCHER_AI_GATEWAY_GROUP_KEY,
LAUNCHER_FLAT_GROUP_KEY,
LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY,
@@ -157,7 +159,8 @@ async function resolveAccessibleIdsUncached(
.where(
and(
eq(userResources.userId, userId),
eq(resources.orgId, orgId)
eq(resources.orgId, orgId),
eq(resources.status, "approved")
)
),
userRoleIds.length > 0
@@ -171,7 +174,8 @@ async function resolveAccessibleIdsUncached(
.where(
and(
inArray(roleResources.roleId, userRoleIds),
eq(resources.orgId, orgId)
eq(resources.orgId, orgId),
eq(resources.status, "approved")
)
)
: Promise.resolve([]),
@@ -183,7 +187,11 @@ async function resolveAccessibleIdsUncached(
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
)
.where(
and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId))
and(
eq(userPolicies.userId, userId),
eq(resources.orgId, orgId),
eq(resources.status, "approved")
)
),
userRoleIds.length > 0
? db
@@ -199,21 +207,48 @@ async function resolveAccessibleIdsUncached(
.where(
and(
inArray(rolePolicies.roleId, userRoleIds),
eq(resources.orgId, orgId)
eq(resources.orgId, orgId),
eq(resources.status, "approved")
)
)
: Promise.resolve([]),
db
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.where(eq(userSiteResources.userId, userId)),
.innerJoin(
siteResources,
eq(
userSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(userSiteResources.userId, userId),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
),
userRoleIds.length > 0
? db
.select({
siteResourceId: roleSiteResources.siteResourceId
})
.from(roleSiteResources)
.where(inArray(roleSiteResources.roleId, userRoleIds))
.innerJoin(
siteResources,
eq(
roleSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
inArray(roleSiteResources.roleId, userRoleIds),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
)
: Promise.resolve([])
]);
@@ -274,16 +309,14 @@ function combineOrConditions(
return or(...parts);
}
function buildSearchConditionForPublic(
query: string,
labelsFeatureEnabled: boolean
) {
function buildSearchConditionForPublic(query: string) {
if (!query.trim()) {
return undefined;
}
const pattern = searchPattern(query.toLowerCase());
const queryList = [
like(sql`LOWER(${resources.name})`, pattern),
like(sql`LOWER(${resources.niceId})`, pattern),
like(sql`LOWER(${resources.fullDomain})`, pattern),
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
inArray(
@@ -295,38 +328,28 @@ function buildSearchConditionForPublic(
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
),
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(labels, eq(labels.labelId, resourceLabels.labelId))
.where(like(sql`LOWER(${labels.name})`, pattern))
)
];
if (labelsFeatureEnabled) {
queryList.push(
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, pattern))
)
);
}
return or(...queryList);
}
function buildSearchConditionForSiteResource(
query: string,
labelsFeatureEnabled: boolean
) {
function buildSearchConditionForSiteResource(query: string) {
if (!query.trim()) {
return undefined;
}
const pattern = searchPattern(query.toLowerCase());
const queryList = [
like(sql`LOWER(${siteResources.name})`, pattern),
like(sql`LOWER(${siteResources.niceId})`, pattern),
like(sql`LOWER(${siteResources.destination})`, pattern),
like(
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
@@ -335,40 +358,34 @@ function buildSearchConditionForSiteResource(
like(sql`LOWER(${siteResources.scheme})`, pattern),
like(sql`LOWER(${siteResources.alias})`, pattern),
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
like(sql`LOWER(${siteResources.aliasAddress})`, pattern),
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, pattern))
)
];
if (labelsFeatureEnabled) {
queryList.push(
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, pattern))
)
);
}
return or(...queryList);
}
async function filterPublicResourceIdsByTextSearch(
orgId: string,
resourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
query: string
): Promise<number[]> {
if (!query.trim() || resourceIds.length === 0) {
return resourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForPublic(query, labelsFeatureEnabled),
buildSearchConditionForPublic(query),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
@@ -385,6 +402,7 @@ async function filterPublicResourceIdsByTextSearch(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved"),
textMatch
)
);
@@ -395,15 +413,14 @@ async function filterPublicResourceIdsByTextSearch(
async function filterSiteResourceIdsByTextSearch(
orgId: string,
siteResourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
query: string
): Promise<number[]> {
if (!query.trim() || siteResourceIds.length === 0) {
return siteResourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
buildSearchConditionForSiteResource(query),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
@@ -423,6 +440,7 @@ async function filterSiteResourceIdsByTextSearch(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
textMatch
)
);
@@ -430,10 +448,6 @@ async function filterSiteResourceIdsByTextSearch(
return rows.map((row) => row.siteResourceId);
}
async function labelsEnabled(orgId: string): Promise<boolean> {
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
}
async function fetchLabelsForResources(
orgId: string,
resourceIds: number[],
@@ -445,10 +459,6 @@ async function fetchLabelsForResources(
const byResourceId = new Map<number, LauncherLabel[]>();
const bySiteResourceId = new Map<number, LauncherLabel[]>();
if (!(await labelsEnabled(orgId))) {
return { byResourceId, bySiteResourceId };
}
const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([
resourceIds.length === 0
? Promise.resolve([])
@@ -524,22 +534,16 @@ async function listSiteGroups(
): Promise<{ groups: LauncherGroup[]; total: number }> {
const siteFilterIds = parseIdListParam(query.siteIds);
const labelFilterIds = parseIdListParam(query.labelIds);
const labelsFeatureEnabled = await labelsEnabled(orgId);
const searchPublic = buildSearchConditionForPublic(
query.query,
labelsFeatureEnabled
);
const searchSite = buildSearchConditionForSiteResource(
query.query,
labelsFeatureEnabled
);
const searchPublic = buildSearchConditionForPublic(query.query);
const searchSite = buildSearchConditionForSiteResource(query.query);
const siteCountMap = new Map<number, SiteGroupRow>();
if (accessible.resourceIds.length > 0) {
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
eq(resources.enabled, true),
eq(resources.status, "approved")
];
if (searchPublic) {
publicConditions.push(searchPublic);
@@ -594,7 +598,8 @@ async function listSiteGroups(
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
];
if (searchSite) {
siteConditions.push(searchSite);
@@ -651,39 +656,63 @@ async function listSiteGroups(
}
}
let aiGatewayCount = 0;
let noSiteCount = 0;
if (accessible.resourceIds.length > 0 && siteFilterIds.length === 0) {
const noSitePublicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
eq(resources.enabled, true),
eq(resources.status, "approved")
];
if (searchPublic) {
noSitePublicConditions.push(searchPublic);
}
let noSitePublicQuery = db
.select({
itemCount: countDistinct(resources.resourceId)
})
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId));
const buildNoSitePublicQuery = () => {
let queryBuilder = db
.select({
itemCount: countDistinct(resources.resourceId)
})
.from(resources)
.leftJoin(
targets,
eq(targets.resourceId, resources.resourceId)
);
if (labelFilterIds.length > 0) {
queryBuilder = queryBuilder.innerJoin(
resourceLabels,
eq(resourceLabels.resourceId, resources.resourceId)
);
}
return queryBuilder;
};
if (labelFilterIds.length > 0) {
noSitePublicQuery = noSitePublicQuery.innerJoin(
resourceLabels,
eq(resourceLabels.resourceId, resources.resourceId)
);
noSitePublicConditions.push(
inArray(resourceLabels.labelId, labelFilterIds)
);
}
const [noSitePublicRow] = await noSitePublicQuery.where(
and(...noSitePublicConditions, isNull(targets.targetId))
const [aiGatewayPublicRow] = await buildNoSitePublicQuery().where(
and(
...noSitePublicConditions,
isNull(targets.targetId),
eq(resources.mode, "inference")
)
);
const [noSitePublicRow] = await buildNoSitePublicQuery().where(
and(
...noSitePublicConditions,
isNull(targets.targetId),
ne(resources.mode, "inference")
)
);
aiGatewayCount += Number(aiGatewayPublicRow?.itemCount ?? 0);
noSiteCount += Number(noSitePublicRow?.itemCount ?? 0);
}
@@ -691,44 +720,64 @@ async function listSiteGroups(
const noSiteSiteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
];
if (searchSite) {
noSiteSiteConditions.push(searchSite);
}
let noSiteSiteQuery = db
.select({
itemCount: countDistinct(siteResources.siteResourceId)
})
.from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId));
const buildNoSiteSiteQuery = () => {
let queryBuilder = db
.select({
itemCount: countDistinct(siteResources.siteResourceId)
})
.from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId));
if (labelFilterIds.length > 0) {
queryBuilder = queryBuilder.innerJoin(
siteResourceLabels,
eq(
siteResourceLabels.siteResourceId,
siteResources.siteResourceId
)
);
}
return queryBuilder;
};
if (labelFilterIds.length > 0) {
noSiteSiteQuery = noSiteSiteQuery.innerJoin(
siteResourceLabels,
eq(
siteResourceLabels.siteResourceId,
siteResources.siteResourceId
)
);
noSiteSiteConditions.push(
inArray(siteResourceLabels.labelId, labelFilterIds)
);
}
const [noSiteSiteRow] = await noSiteSiteQuery.where(
and(...noSiteSiteConditions, isNull(sites.siteId))
const [aiGatewaySiteRow] = await buildNoSiteSiteQuery().where(
and(
...noSiteSiteConditions,
isNull(sites.siteId),
eq(siteResources.mode, "inference")
)
);
const [noSiteSiteRow] = await buildNoSiteSiteQuery().where(
and(
...noSiteSiteConditions,
isNull(sites.siteId),
ne(siteResources.mode, "inference")
)
);
aiGatewayCount += Number(aiGatewaySiteRow?.itemCount ?? 0);
noSiteCount += Number(noSiteSiteRow?.itemCount ?? 0);
}
let groups: LauncherGroup[] = Array.from(siteCountMap.values()).map(
const siteGroups: LauncherGroup[] = Array.from(siteCountMap.values()).map(
(row) => ({
groupKey: String(row.siteId),
name: row.name,
@@ -739,8 +788,26 @@ async function listSiteGroups(
})
);
siteGroups.sort((a, b) => {
const cmp = a.name.localeCompare(b.name, undefined, {
sensitivity: "base"
});
return query.order === "desc" ? -cmp : cmp;
});
const pinnedGroups: LauncherGroup[] = [];
if (aiGatewayCount > 0 && siteFilterIds.length === 0) {
pinnedGroups.push({
groupKey: LAUNCHER_AI_GATEWAY_GROUP_KEY,
name: "AI Gateway",
groupType: "site",
itemCount: aiGatewayCount
});
}
if (noSiteCount > 0 && siteFilterIds.length === 0) {
groups.push({
pinnedGroups.push({
groupKey: LAUNCHER_NO_SITE_GROUP_KEY,
name: "No Site",
groupType: "site",
@@ -748,12 +815,7 @@ async function listSiteGroups(
});
}
groups.sort((a, b) => {
const cmp = a.name.localeCompare(b.name, undefined, {
sensitivity: "base"
});
return query.order === "desc" ? -cmp : cmp;
});
const groups = [...pinnedGroups, ...siteGroups];
const total = groups.length;
return {
@@ -775,10 +837,6 @@ async function listLabelGroups(
>();
let unlabeledCount = 0;
if (!(await labelsEnabled(orgId))) {
return { groups: [], total: 0 };
}
const matchesLabelFilters = (labelId: number) =>
labelFilterIds.length === 0 || labelFilterIds.includes(labelId);
@@ -786,9 +844,10 @@ async function listLabelGroups(
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
eq(resources.enabled, true),
eq(resources.status, "approved")
];
const searchPublic = buildSearchConditionForPublic(query.query, true);
const searchPublic = buildSearchConditionForPublic(query.query);
if (searchPublic) {
publicConditions.push(searchPublic);
}
@@ -850,12 +909,10 @@ async function listLabelGroups(
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
];
const searchSite = buildSearchConditionForSiteResource(
query.query,
true
);
const searchSite = buildSearchConditionForSiteResource(query.query);
if (searchSite) {
siteConditions.push(searchSite);
}
@@ -1040,6 +1097,7 @@ async function mapPublicResources(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved"),
siteIdFilter != null
? eq(sites.siteId, siteIdFilter)
: undefined
@@ -1131,6 +1189,7 @@ async function mapSiteResources(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
siteIdFilter != null
? eq(sites.siteId, siteIdFilter)
: undefined
@@ -1189,8 +1248,11 @@ function filterResourcesBySite(
items: LauncherResource[],
groupKey: string
): LauncherResource[] {
if (groupKey === LAUNCHER_AI_GATEWAY_GROUP_KEY) {
return items.filter((item) => item.mode === "inference");
}
if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) {
return items.filter((item) => !item.site);
return items.filter((item) => !item.site && item.mode !== "inference");
}
const siteId = Number.parseInt(groupKey, 10);
if (!Number.isFinite(siteId)) {
@@ -1302,23 +1364,19 @@ async function listLauncherResourcesForUserUncached(
}
}
const labelsFeatureEnabled = await labelsEnabled(orgId);
if (query.query.trim()) {
if (filteredResourceIds.length > 0) {
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
orgId,
filteredResourceIds,
query.query,
labelsFeatureEnabled
query.query
);
}
if (filteredSiteResourceIds.length > 0) {
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
orgId,
filteredSiteResourceIds,
query.query,
labelsFeatureEnabled
query.query
);
}
}
@@ -1331,7 +1389,8 @@ async function listLauncherResourcesForUserUncached(
const parsedSiteId =
query.groupBy === "site" &&
query.groupKey !== LAUNCHER_NO_SITE_GROUP_KEY
query.groupKey !== LAUNCHER_NO_SITE_GROUP_KEY &&
query.groupKey !== LAUNCHER_AI_GATEWAY_GROUP_KEY
? Number.parseInt(query.groupKey, 10)
: Number.NaN;
const siteIdFilter = Number.isFinite(parsedSiteId)
@@ -1429,7 +1488,8 @@ async function collectAccessibleSites(
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
eq(resources.enabled, true),
eq(resources.status, "approved")
];
if (siteNameSearch) {
publicConditions.push(siteNameSearch);
@@ -1469,7 +1529,8 @@ async function collectAccessibleSites(
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
];
if (siteNameSearch) {
siteConditions.push(siteNameSearch);
@@ -1518,15 +1579,12 @@ async function collectAccessibleLabels(
): Promise<Map<number, LauncherLabel>> {
const labelMap = new Map<number, LauncherLabel>();
if (!(await labelsEnabled(orgId))) {
return labelMap;
}
if (accessible.resourceIds.length > 0) {
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved"),
eq(labels.orgId, orgId)
];
if (labelNameSearch) {
@@ -1562,6 +1620,7 @@ async function collectAccessibleLabels(
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
eq(labels.orgId, orgId)
];
if (labelNameSearch) {
@@ -0,0 +1,172 @@
import { db, resources, siteResources } from "@server/db";
import { listEffectiveAllowModels } from "@server/lib/aiInferenceResource";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
const publicParamsSchema = z.strictObject({
orgId: z.string().min(1),
resourceId: z.coerce.number().int().positive()
});
const siteParamsSchema = z.strictObject({
orgId: z.string().min(1),
siteResourceId: z.coerce.number().int().positive()
});
export type ListLauncherAiModelsResponse = {
models: Awaited<ReturnType<typeof listEffectiveAllowModels>>;
};
export async function listLauncherPublicAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = publicParamsSchema.safeParse(req.params);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { resourceId } = parsed.data;
const [resource] = await db
.select({
resourceId: resources.resourceId,
mode: resources.mode
})
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource || resource.mode !== "inference") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI models are only available for inference resources"
)
);
}
const models = await listEffectiveAllowModels({ resourceId });
return response<ListLauncherAiModelsResponse>(res, {
data: { models },
success: true,
error: false,
message: "Launcher AI models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher AI models:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
export async function listLauncherSiteAiModels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = siteParamsSchema.safeParse(req.params);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { siteResourceId } = parsed.data;
const siteResource =
req.siteResource ??
(
await db
.select({
siteResourceId: siteResources.siteResourceId,
mode: siteResources.mode,
orgId: siteResources.orgId
})
.from(siteResources)
.where(
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
)
.limit(1)
)[0];
if (
!siteResource ||
siteResource.orgId !== orgId ||
siteResource.mode !== "inference"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"AI models are only available for inference resources"
)
);
}
const models = await listEffectiveAllowModels({ siteResourceId });
return response<ListLauncherAiModelsResponse>(res, {
data: { models },
success: true,
error: false,
message: "Launcher AI models retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher AI models:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
+1
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
export const LAUNCHER_AI_GATEWAY_GROUP_KEY = "ai-gateway";
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
export const launcherViewConfigSchema = z.object({
+24 -7
View File
@@ -15,10 +15,11 @@ 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 {
batchFetchCertsForSiteResources,
formatEndpoint,
generateSubnetProxyTargetV2,
SubnetProxyTargetV2
@@ -148,7 +149,12 @@ export async function buildClientConfigurationForNewtClient(
.from(siteResources)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(eq(siteNetworks.siteId, siteId))
.where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.enabled, true)
)
)
.then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = [];
@@ -201,11 +207,19 @@ export async function buildClientConfigurationForNewtClient(
});
}
// Batch-fetch certs for every SSL-enabled HTTP resource's domain in one
// 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 resourceTargetsArr = await Promise.all(
allSiteResources.map((resource) =>
generateSubnetProxyTargetV2(
resource,
clientsByResourceId.get(resource.siteResourceId) ?? []
clientsByResourceId.get(resource.siteResourceId) ?? [],
certByDomain
)
)
);
@@ -227,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,
@@ -237,15 +251,18 @@ export async function buildTargetConfigurationForNewtClient(
port: targets.port,
internalPort: targets.internalPort,
enabled: targets.enabled,
mode: resources.mode
mode: sql<string>`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))
)
);
+75
View File
@@ -0,0 +1,75 @@
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import {
canCompress,
supportsCertReferences
} from "@server/lib/clientVersionChecks";
import { CertRef } from "@server/lib/ip";
/**
* Pushes an incremental set of certs to a newt client outside of a full
* newt/sync or newt/wg/receive-config, e.g. after a certificate renewal so
* that every target referencing it (by tlsCertId) picks up the new material
* without waiting for the next full resync.
*/
export async function sendCertsAdd(
newtId: string,
certs: CertRef[],
version?: string | null
) {
if (certs.length === 0) {
return;
}
if (!supportsCertReferences(version)) {
logger.debug(
`Newt ${newtId} (version ${version}) does not support cert references, skipping certs/add`
);
return;
}
await sendToClient(
newtId,
{
type: "newt/certs/add",
data: certs
},
{
incrementConfigVersion: true,
compress: canCompress(version, "newt")
}
);
}
/**
* Tells a newt client to drop the given cert IDs, e.g. once the server knows
* no target references them anymore.
*/
export async function sendCertsRemove(
newtId: string,
certIds: string[],
version?: string | null
) {
if (certIds.length === 0) {
return;
}
if (!supportsCertReferences(version)) {
logger.debug(
`Newt ${newtId} (version ${version}) does not support cert references, skipping certs/remove`
);
return;
}
await sendToClient(
newtId,
{
type: "newt/certs/remove",
data: { ids: certIds }
},
{
incrementConfigVersion: true,
compress: canCompress(version, "newt")
}
);
}
@@ -2,14 +2,17 @@ import { db, sites } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt } from "@server/db";
import logger from "@server/logger";
import { ne, eq, or, and, count } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { calculateExitNodeWeight } from "@server/lib/exitNodes";
export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
export const handleNewtExitNodesRequestMessage: MessageHandler = async (
context
) => {
const { message, client, sendToClient } = context;
const newt = client as Newt;
logger.info("Handling ping request newt message!");
logger.info("Handling exit nodes request newt message!");
if (!newt) {
logger.warn("Newt not found");
@@ -54,32 +57,13 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
const exitNodesPayload = await Promise.all(
exitNodesList.map(async (node) => {
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
// higher = more desirable
// like saying, this node has x% of its capacity left
const weight = await calculateExitNodeWeight(
node.exitNodeId,
node.maxConnections
);
let weight = 1;
const maxConnections = node.maxConnections;
if (maxConnections !== null && maxConnections !== undefined) {
const [currentConnections] = await db
.select({
count: count()
})
.from(sites)
.where(
and(
eq(sites.exitNodeId, node.exitNodeId),
eq(sites.online, true)
)
);
if (currentConnections.count >= maxConnections) {
return null;
}
weight =
(maxConnections - currentConnections.count) /
maxConnections;
if (weight === null) {
return null;
}
return {
@@ -7,7 +7,11 @@ import { eq } from "drizzle-orm";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNecessary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks";
import {
canCompress,
supportsCertReferences
} from "@server/lib/clientVersionChecks";
import { dedupeCertsForTargets } from "@server/lib/ip";
import config from "@server/lib/config";
import { waitForSiteRebuildIdle } from "@server/lib/rebuildClientAssociations";
@@ -29,7 +33,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
return;
}
const { publicKey, port, chainId } = message.data;
const { publicKey, port, localEndpoints, chainId } = message.data;
const siteId = newt.siteId;
// Get the current site data
@@ -69,7 +73,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
.update(sites)
.set({
publicKey,
listenPort: port
listenPort: port,
localEndpoints: localEndpoints
? JSON.stringify(localEndpoints)
: null
})
.where(eq(sites.siteId, siteId))
.returning();
@@ -88,16 +95,16 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
.limit(1);
if (
exitNode.reachableAt &&
existingSite.subnet &&
existingSite.exitNodeSubnet &&
existingSite.listenPort
) {
const payload = {
oldDestination: {
destinationIP: existingSite.subnet?.split("/")[0],
destinationIP: existingSite.exitNodeSubnet?.split("/")[0],
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
},
newDestination: {
destinationIP: site.subnet?.split("/")[0],
destinationIP: site.exitNodeSubnet?.split("/")[0],
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
}
};
@@ -116,7 +123,19 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
exitNode
);
const targetsToSend = await convertTargetsIfNecessary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
// Older newt clients only understand inline tlsCert/tlsKey on each
// target, so only switch to certId references once we know the client
// can resolve them.
let dedupedTargets = targets;
let certs: { id: string; cert: string; key: string }[] = [];
if (supportsCertReferences(newt.version)) {
({ targets: dedupedTargets, certs } = dedupeCertsForTargets(targets));
}
const targetsToSend = await convertTargetsIfNecessary(
newt.newtId,
dedupedTargets
); // for backward compatibility with old newt versions that don't support the new target format
return {
message: {
@@ -125,6 +144,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
ipAddress: site.address,
peers,
targets: targetsToSend,
certs,
chainId: chainId
}
},
@@ -1,30 +1,20 @@
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db";
import { db, newts, remoteExitNodes } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
import { addPeer, deletePeer } from "../gerbil/peers";
import logger from "@server/logger";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import {
ExitNodePingResult,
selectBestExitNode,
verifyExitNodeOrgAccess
} from "#dynamic/lib/exitNodes";
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
import { fetchContainers } from "./dockerSocket";
import { lockManager } from "#dynamic/lib/lock";
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks";
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
const newt = client as Newt;
@@ -94,9 +84,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
fetchContainers(newt.newtId);
}
let siteSubnet = oldSite.subnet;
let siteSubnet = oldSite.exitNodeSubnet;
let exitNodeIdToQuery = oldSite.exitNodeId;
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
if (
exitNodeId &&
(oldSite.exitNodeId !== exitNodeId || !oldSite.exitNodeSubnet)
) {
// This effectively moves the exit node to the new one
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
@@ -115,7 +108,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
return;
}
const newSubnet = await getUniqueSubnetForSite(exitNode);
const newSubnet = await getUniqueSubnetForExitNode(exitNode);
if (!newSubnet) {
logger.error(
@@ -131,7 +124,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.set({
pubKey: publicKey,
exitNodeId: exitNodeId,
subnet: newSubnet
exitNodeSubnet: newSubnet
})
.where(eq(sites.siteId, siteId))
.returning();
@@ -250,40 +243,3 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
excludeSender: false // Include sender in broadcast
};
};
async function getUniqueSubnetForSite(
exitNode: ExitNode,
trx: Transaction | typeof db = db
): Promise<string | null> {
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
return await lockManager.withLock(
lockKey,
async () => {
const sitesQuery = await trx
.select({
subnet: sites.subnet
})
.from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = sitesQuery
.map((site) => site.subnet)
.filter(
(subnet) =>
subnet &&
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
)
.filter((subnet) => subnet !== null);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
const newSubnet = findNextAvailableCidr(
subnets,
blockSize,
exitNode.address
);
return newSubnet;
},
5000 // 5 second lock TTL - subnet allocation should be quick
);
}
+241 -2
View File
@@ -1,9 +1,248 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { inflate } from "zlib";
import { promisify } from "util";
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
import { getCountryCodeForIp } from "@server/lib/geoip";
export async function flushRequestLogToDb(): Promise<void> {
return;
}
const zlibInflate = promisify(inflate);
interface HTTPRequestLogData {
requestId: string;
resourceId: number; // siteResourceId
timestamp: string; // ISO 8601
method: string;
scheme: string; // "http" or "https"
host: string;
path: string;
rawQuery?: string;
userAgent?: string;
sourceAddr: string; // ip:port
tls: boolean;
}
/**
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
*/
async function decompressRequestLog(
compressed: string
): Promise<HTTPRequestLogData[]> {
const compressedBuffer = Buffer.from(compressed, "base64");
const decompressed = await zlibInflate(compressedBuffer);
const jsonString = decompressed.toString("utf-8");
const parsed = JSON.parse(jsonString);
if (!Array.isArray(parsed)) {
throw new Error("Decompressed request log data is not an array");
}
return parsed;
}
export const handleRequestLogMessage: MessageHandler = async (context) => {
return;
};
const { message, client } = context;
const newt = client as Newt;
if (!newt) {
logger.warn("Request log received but no newt client in context");
return;
}
if (!newt.siteId) {
logger.warn("Request log received but newt has no siteId");
return;
}
if (!message.data?.compressed) {
logger.warn("Request log message missing compressed data");
return;
}
// Look up the org for this site and check retention settings
const [site] = await db
.select({
orgId: sites.orgId,
orgSubnet: orgs.subnet,
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest
})
.from(sites)
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(eq(sites.siteId, newt.siteId));
if (!site) {
logger.warn(
`Request log received but site ${newt.siteId} not found in database`
);
return;
}
const orgId = site.orgId;
if (site.settingsLogRetentionDaysRequest === 0) {
logger.debug(
`Request log retention is disabled for org ${orgId}, skipping`
);
return;
}
let entries: HTTPRequestLogData[];
try {
entries = await decompressRequestLog(message.data.compressed);
} catch (error) {
logger.error("Failed to decompress request log data:", error);
return;
}
if (entries.length === 0) {
return;
}
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
// Build a map from sourceIp → external endpoint string by joining clients
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
// of the client device and is used for GeoIP lookup.
const ipToEndpoint = new Map<string, string>();
// Build a map from sourceIp → the user associated with the client (if any)
const ipToUser = new Map<string, { username: string; userId: string }>();
const cidrSuffix = site.orgSubnet?.includes("/")
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
: null;
if (cidrSuffix) {
const uniqueSourceAddrs = new Set<string>();
for (const entry of entries) {
if (entry.sourceAddr) {
uniqueSourceAddrs.add(entry.sourceAddr);
}
}
if (uniqueSourceAddrs.size > 0) {
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
return `${ip}${cidrSuffix}`;
});
const matchedClients = await db
.select({
subnet: clients.subnet,
endpoint: clientSitesAssociationsCache.endpoint,
username: users.username,
userId: users.userId
})
.from(clients)
.innerJoin(
clientSitesAssociationsCache,
and(
eq(
clientSitesAssociationsCache.clientId,
clients.clientId
),
eq(clientSitesAssociationsCache.siteId, newt.siteId)
)
)
.leftJoin(users, eq(clients.userId, users.userId))
.where(
and(
eq(clients.orgId, orgId),
inArray(clients.subnet, subnetQueries)
)
);
for (const c of matchedClients) {
const ip = c.subnet.split("/")[0];
if (c.endpoint) {
ipToEndpoint.set(ip, c.endpoint);
}
if (c.userId && c.username) {
ipToUser.set(ip, { userId: c.userId, username: c.username });
}
}
}
}
for (const entry of entries) {
if (
!entry.requestId ||
!entry.resourceId ||
!entry.method ||
!entry.scheme ||
!entry.host ||
!entry.path ||
!entry.sourceAddr
) {
logger.debug(
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
);
continue;
}
const originalRequestURL =
entry.scheme +
"://" +
entry.host +
entry.path +
(entry.rawQuery ? "?" + entry.rawQuery : "");
// Resolve the client's external endpoint for GeoIP lookup.
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
const sourceIp = entry.sourceAddr.includes(":")
? entry.sourceAddr.split(":")[0]
: entry.sourceAddr;
const endpoint = ipToEndpoint.get(sourceIp);
let location: string | undefined;
if (endpoint) {
const endpointIp = endpoint.includes(":")
? endpoint.split(":")[0]
: endpoint;
location = await getCountryCodeForIp(endpointIp);
}
const user = ipToUser.get(sourceIp);
await logRequestAudit(
{
action: true,
reason: 108,
siteResourceId: entry.resourceId,
orgId,
location,
user
},
{
path: entry.path,
originalRequestURL,
scheme: entry.scheme,
host: entry.host,
method: entry.method,
tls: entry.tls,
requestIp: entry.sourceAddr
}
);
}
logger.debug(
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
);
};

Some files were not shown because too many files have changed in this diff Show More