diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 1a4013f0f..fdb3f2112 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1984,7 +1984,7 @@ export const aiSessionLog = pgTable( // were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage. truncated: boolean("truncated").notNull().default(false), statusCode: integer("statusCode"), - createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms + createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds }, (t) => [ index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4d3a7482f..47853432e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1980,7 +1980,7 @@ export const aiSessionLog = sqliteTable( .notNull() .default(false), statusCode: integer("statusCode"), - createdAt: integer("createdAt").notNull() // epoch ms + createdAt: integer("createdAt").notNull() // epoch seconds }, (t) => [ index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt), diff --git a/server/lib/aiBudgetEnforcement.ts b/server/lib/aiBudgetEnforcement.ts index 312d41297..104e11d21 100644 --- a/server/lib/aiBudgetEnforcement.ts +++ b/server/lib/aiBudgetEnforcement.ts @@ -580,6 +580,8 @@ export async function recordUsage(input: UsageRecordInput): Promise { ); } + const timestamp = Math.floor(Date.now() / 1000); + usageRecordBuffer.push({ orgId: input.orgId, providerId: input.providerId, @@ -597,7 +599,7 @@ export async function recordUsage(input: UsageRecordInput): Promise { totalTokens, costUsd: input.costUsd, estimated: usage.estimated, - createdAt: input.createdAt ?? Date.now() + createdAt: input.createdAt ?? timestamp }); // Flush immediately if buffer is full, otherwise schedule a flush diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts index 138ffc74f..1862e27b9 100644 --- a/server/lib/domainUtils.ts +++ b/server/lib/domainUtils.ts @@ -52,8 +52,7 @@ export async function validateAndConstructDomain( }; } - // Check if organization has access to domain - if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) { + if (!domainRes.orgDomains) { return { success: false, error: `Organization does not have access to domain with ID ${domainId}` diff --git a/server/private/routers/aiGateway/logAiSession.ts b/server/private/routers/aiGateway/logAiSession.ts index 47b0f039e..1532aa5c5 100644 --- a/server/private/routers/aiGateway/logAiSession.ts +++ b/server/private/routers/aiGateway/logAiSession.ts @@ -151,17 +151,14 @@ async function getRetentionDays(orgId: string): Promise { } export async function cleanUpOldLogs(orgId: string, retentionDays: number) { - // calculateCutoffTimestamp returns a seconds-epoch cutoff (built for - // requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to - // match aiUsageRecords - convert before comparing. - const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000; + const cutoffTimestamp = calculateCutoffTimestamp(retentionDays) * 1000; try { await logsDb .delete(aiSessionLog) .where( and( - lt(aiSessionLog.createdAt, cutoffTimestampMs), + lt(aiSessionLog.createdAt, cutoffTimestamp), eq(aiSessionLog.orgId, orgId) ) ); @@ -243,6 +240,8 @@ export function logAiSession(data: { ); } + const timestamp = Math.floor(Date.now() / 1000); + sessionLogBuffer.push({ sessionId: data.sessionId, orgId: sanitizeString(data.orgId), @@ -270,7 +269,7 @@ export function logAiSession(data: { (normalizedRequestText?.truncated ?? false) || (normalizedResponseText?.truncated ?? false), statusCode: data.statusCode, - createdAt: Date.now() + createdAt: timestamp }); // Flush immediately if buffer is full, otherwise schedule a flush diff --git a/server/routers/auditLogs/aiUsageAnalyticsShared.ts b/server/routers/auditLogs/aiUsageAnalyticsShared.ts index ada94a9b9..af2b7b20e 100644 --- a/server/routers/auditLogs/aiUsageAnalyticsShared.ts +++ b/server/routers/auditLogs/aiUsageAnalyticsShared.ts @@ -18,7 +18,7 @@ export const aiUsageAnalyticsFiltersQuery = z.object({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) - .transform((val) => new Date(val).getTime()) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .prefault(() => getSevenDaysAgo().toISOString()) .openapi({ type: "string", @@ -31,7 +31,7 @@ export const aiUsageAnalyticsFiltersQuery = z.object({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) - .transform((val) => new Date(val).getTime()) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .prefault(() => new Date().toISOString()) .openapi({ type: "string", @@ -122,12 +122,12 @@ export function buildAiUsageWhere( ); } -// Buckets createdAt (epoch ms) down to a per-day string, dialect-aware, same -// approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts. +// 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`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt} / 1000.0))` - : sql`DATE(${aiUsageRecords.createdAt} / 1000, 'unixepoch')`; + ? sql`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt}))` + : sql`DATE(${aiUsageRecords.createdAt}, 'unixepoch')`; } export type DailyMetricRow = { diff --git a/server/routers/auditLogs/queryAiSessionLog.ts b/server/routers/auditLogs/queryAiSessionLog.ts index 24f0065f5..3d0309905 100644 --- a/server/routers/auditLogs/queryAiSessionLog.ts +++ b/server/routers/auditLogs/queryAiSessionLog.ts @@ -32,7 +32,7 @@ export const queryAiSessionLogsQuery = z.strictObject({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) - .transform((val) => new Date(val).getTime()) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .prefault(() => getSevenDaysAgo().toISOString()) .openapi({ type: "string", @@ -45,7 +45,7 @@ export const queryAiSessionLogsQuery = z.strictObject({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) - .transform((val) => new Date(val).getTime()) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .prefault(() => new Date().toISOString()) .openapi({ diff --git a/server/routers/auditLogs/queryAiUsageFilterOptions.ts b/server/routers/auditLogs/queryAiUsageFilterOptions.ts index cd0f42cb6..bab6ed842 100644 --- a/server/routers/auditLogs/queryAiUsageFilterOptions.ts +++ b/server/routers/auditLogs/queryAiUsageFilterOptions.ts @@ -30,14 +30,14 @@ const queryAiUsageFilterOptionsQuery = z.object({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) - .transform((val) => new Date(val).getTime()) + .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) => new Date(val).getTime()) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .prefault(() => new Date().toISOString()) }); diff --git a/server/routers/external.ts b/server/routers/external.ts index f4442bd08..d2935ff91 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -66,6 +66,10 @@ 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(); @@ -1927,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)); @@ -1942,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)); @@ -1959,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)); @@ -2037,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.`; @@ -2053,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.`; @@ -2085,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)); @@ -2103,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)); @@ -2125,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)); @@ -2141,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)); @@ -2188,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)); @@ -2240,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.`; diff --git a/server/routers/org/getOrg.ts b/server/routers/org/getOrg.ts index 642efb5b9..bd8746e17 100644 --- a/server/routers/org/getOrg.ts +++ b/server/routers/org/getOrg.ts @@ -16,13 +16,12 @@ const getOrgSchema = z.strictObject({ }); export type GetOrgResponse = { - org: Org; + org: Omit; }; const GetOrgResponseDataSchema = z.object({ org: z.object({}).passthrough() }); - registry.registerPath({ method: "get", path: "/org/{orgId}", @@ -76,9 +75,12 @@ export async function getOrg( ); } + // sshCaPrivateKey is encrypted anyway but just to be safe + const { sshCaPrivateKey: _, ...orgWithoutPrivateKey } = org; + return response(res, { data: { - org + org: orgWithoutPrivateKey }, success: true, error: false, diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index 47a89d1b1..3074c19bf 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -18,7 +18,7 @@ const getSiteResourceParamsSchema = z.strictObject({ .pipe(z.int().positive().optional()) .optional(), niceId: z.string().optional(), - orgId: z.string() + orgId: z.string().optional() }); async function query(siteResourceId?: number, niceId?: string, orgId?: string) { @@ -34,6 +34,13 @@ async function query(siteResourceId?: number, niceId?: string, orgId?: string) { ) .limit(1); return siteResource; + } else if (siteResourceId) { + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + return siteResource; } else if (niceId && orgId) { const [siteResource] = await db .select() @@ -60,9 +67,7 @@ registry.registerPath({ tags: [OpenAPITags.PrivateResourceLegacy], request: { params: z.object({ - siteResourceId: z.number(), - siteId: z.number(), - orgId: z.string() + siteResourceId: z.number() }) }, responses: { @@ -90,9 +95,7 @@ registry.registerPath({ tags: [OpenAPITags.PrivateResource], request: { params: z.object({ - siteResourceId: z.number(), - siteId: z.number(), - orgId: z.string() + siteResourceId: z.number() }) }, responses: {