From 40606ce39f93d815bc040dd589eeb92537bf28b9 Mon Sep 17 00:00:00 2001 From: Loi Phan Date: Sat, 11 Jul 2026 17:24:27 +0700 Subject: [PATCH] feat: update the permission for showing the usage of the user --- .../codex-reset-credits/route.js | 13 +- src/app/api/usage/[connectionId]/route.js | 5 +- src/app/api/usage/chart/route.js | 5 +- src/app/api/usage/history/route.js | 5 +- src/app/api/usage/logs/route.js | 5 +- src/app/api/usage/providers/route.js | 5 +- src/app/api/usage/request-details/route.js | 5 +- src/app/api/usage/request-logs/route.js | 5 +- src/app/api/usage/stats/route.js | 5 +- src/app/api/usage/stream/route.js | 14 +- src/lib/auth/currentUser.js | 16 +- src/lib/db/repos/requestDetailsRepo.js | 21 +- src/lib/db/repos/usageAccessScope.js | 61 ++++++ src/lib/db/repos/usageRepo.js | 200 +++++++++++++++++- tests/unit/usage-access-scope.test.js | 61 ++++++ 15 files changed, 399 insertions(+), 27 deletions(-) create mode 100644 src/lib/db/repos/usageAccessScope.js create mode 100644 tests/unit/usage-access-scope.test.js diff --git a/src/app/api/usage/[connectionId]/codex-reset-credits/route.js b/src/app/api/usage/[connectionId]/codex-reset-credits/route.js index 0fb46260..8b42f9cf 100644 --- a/src/app/api/usage/[connectionId]/codex-reset-credits/route.js +++ b/src/app/api/usage/[connectionId]/codex-reset-credits/route.js @@ -5,6 +5,7 @@ import { getProviderConnectionById } from "@/lib/localDb"; import { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "open-sse/services/usage.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { refreshAndUpdateCredentials } from "../route.js"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"]; @@ -47,8 +48,8 @@ function getResponseForConsumeResult(result, redeemRequestId) { }, { status: result.status >= 400 && result.status < 500 ? result.status : 502 }); } -async function getCodexConnection(connectionId) { - const connection = await getProviderConnectionById(connectionId); +async function getCodexConnection(connectionId, user) { + const connection = await getProviderConnectionById(connectionId, user.role === "admin" ? null : user.id); if (!connection) { return { response: Response.json({ error: "Connection not found" }, { status: 404 }) }; } @@ -89,7 +90,8 @@ export async function GET(_request, { params }) { let connection; try { const { connectionId } = await params; - const resolved = await getCodexConnection(connectionId); + const user = await requireUsageDashboardUser(); + const resolved = await getCodexConnection(connectionId, user); if (resolved.response) return resolved.response; ({ connection } = resolved); const { isOAuth, proxyOptions } = resolved; @@ -112,6 +114,7 @@ export async function GET(_request, { params }) { return Response.json(result); } catch (error) { + if (error?.message === "Unauthorized") return Response.json({ error: "Unauthorized" }, { status: 401 }); const provider = connection?.provider ?? "unknown"; console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`); return Response.json({ error: error.message }, { status: 500 }); @@ -122,7 +125,8 @@ export async function POST(request, { params }) { let connection; try { const { connectionId } = await params; - const resolved = await getCodexConnection(connectionId); + const user = await requireUsageDashboardUser(); + const resolved = await getCodexConnection(connectionId, user); if (resolved.response) return resolved.response; ({ connection } = resolved); const { isOAuth, proxyOptions } = resolved; @@ -149,6 +153,7 @@ export async function POST(request, { params }) { return getResponseForConsumeResult(consumeResult, redeemRequestId); } catch (error) { + if (error?.message === "Unauthorized") return Response.json({ error: "Unauthorized" }, { status: 401 }); const provider = connection?.provider ?? "unknown"; console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`); return Response.json({ error: error.message }, { status: 500 }); diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 8ccdc015..95df759b 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -6,6 +6,7 @@ import { getUsageForProvider } from "open-sse/services/usage.js"; import { getExecutor } from "open-sse/executors/index.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; // Detect auth-expired messages returned by usage providers instead of throwing const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"]; @@ -166,9 +167,10 @@ export async function GET(request, { params }) { }, { status: 401 }); } } + const user = await requireUsageDashboardUser(); // Fetch usage from provider API - let usage = await getUsageForProvider(connection, proxyOptions); + connection = await getProviderConnectionById(connectionId, user.role === "admin" ? null : user.id); // If provider returned an auth-expired message instead of throwing, // force-refresh token and retry once (OAuth only) @@ -184,6 +186,7 @@ export async function GET(request, { params }) { return Response.json(usage); } catch (error) { + if (error?.message === "Unauthorized") return Response.json({ error: "Unauthorized" }, { status: 401 }); const provider = connection?.provider ?? "unknown"; console.warn(`[Usage] ${provider}: ${error.message}`); return Response.json({ error: error.message }, { status: 500 }); diff --git a/src/app/api/usage/chart/route.js b/src/app/api/usage/chart/route.js index 063cedd6..35be3519 100644 --- a/src/app/api/usage/chart/route.js +++ b/src/app/api/usage/chart/route.js @@ -1,10 +1,12 @@ import { NextResponse } from "next/server"; import { getChartData } from "@/lib/usageDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d"]); export async function GET(request) { try { + const user = await requireUsageDashboardUser(); const { searchParams } = new URL(request.url); const period = searchParams.get("period") || "7d"; @@ -12,9 +14,10 @@ export async function GET(request) { return NextResponse.json({ error: "Invalid period" }, { status: 400 }); } - const data = await getChartData(period); + const data = await getChartData(period, user); return NextResponse.json(data); } catch (error) { + if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); console.error("[API] Failed to get chart data:", error); return NextResponse.json({ error: "Failed to fetch chart data" }, { status: 500 }); } diff --git a/src/app/api/usage/history/route.js b/src/app/api/usage/history/route.js index 16a5d407..0aba7dde 100644 --- a/src/app/api/usage/history/route.js +++ b/src/app/api/usage/history/route.js @@ -1,11 +1,14 @@ import { NextResponse } from "next/server"; import { getUsageStats } from "@/lib/usageDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; export async function GET() { try { - const stats = await getUsageStats(); + const user = await requireUsageDashboardUser(); + const stats = await getUsageStats("all", user); return NextResponse.json(stats); } catch (error) { + if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); console.error("Error fetching usage stats:", error); return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 }); } diff --git a/src/app/api/usage/logs/route.js b/src/app/api/usage/logs/route.js index b5b875ec..27e02387 100644 --- a/src/app/api/usage/logs/route.js +++ b/src/app/api/usage/logs/route.js @@ -1,11 +1,14 @@ import { NextResponse } from "next/server"; import { getRecentLogs } from "@/lib/usageDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; export async function GET() { try { - const logs = await getRecentLogs(200); + const user = await requireUsageDashboardUser(); + const logs = await getRecentLogs(200, user); return NextResponse.json(logs); } catch (error) { + if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); console.error("Error fetching logs:", error); return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 }); } diff --git a/src/app/api/usage/providers/route.js b/src/app/api/usage/providers/route.js index 8523b8ba..65ac7ee8 100644 --- a/src/app/api/usage/providers/route.js +++ b/src/app/api/usage/providers/route.js @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getDistinctProviders } from "@/lib/requestDetailsDb"; import { getProviderNodes } from "@/lib/localDb"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; /** * GET /api/usage/providers @@ -9,9 +10,10 @@ import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; */ export async function GET() { try { + const user = await requireUsageDashboardUser(); // Query DISTINCT provider column directly — avoids parsing every row's // full JSON blob (can be hundreds of MB), which previously caused OOM. - const providerIds = await getDistinctProviders(); + const providerIds = await getDistinctProviders(user); const providerNodes = await getProviderNodes(); const nodeMap = {}; @@ -32,6 +34,7 @@ export async function GET() { return NextResponse.json({ providers }); } catch (error) { + if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); console.error("[API] Failed to get providers:", error); return NextResponse.json( { error: "Failed to fetch providers" }, diff --git a/src/app/api/usage/request-details/route.js b/src/app/api/usage/request-details/route.js index 9b154497..6b9d8e59 100644 --- a/src/app/api/usage/request-details/route.js +++ b/src/app/api/usage/request-details/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getRequestDetails } from "@/lib/usageDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; /** * GET /api/usage/request-details @@ -7,6 +8,7 @@ import { getRequestDetails } from "@/lib/usageDb"; */ export async function GET(request) { try { + const user = await requireUsageDashboardUser(); const { searchParams } = new URL(request.url); const pageRaw = parseInt(searchParams.get("page")); @@ -46,10 +48,11 @@ export async function GET(request) { if (startDate) filter.startDate = startDate; if (endDate) filter.endDate = endDate; - const result = await getRequestDetails(filter); + const result = await getRequestDetails(filter, user); return NextResponse.json(result); } catch (error) { + if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); console.error("[API] Failed to get request details:", error); return NextResponse.json( { error: "Failed to fetch request details" }, diff --git a/src/app/api/usage/request-logs/route.js b/src/app/api/usage/request-logs/route.js index 0ae5e961..f14f4a4e 100644 --- a/src/app/api/usage/request-logs/route.js +++ b/src/app/api/usage/request-logs/route.js @@ -1,11 +1,14 @@ import { NextResponse } from "next/server"; import { getRecentLogs } from "@/lib/usageDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; export async function GET() { try { - const logs = await getRecentLogs(200); + const user = await requireUsageDashboardUser(); + const logs = await getRecentLogs(200, user); return NextResponse.json(logs); } catch (error) { + if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); console.error("[API ERROR] /api/usage/logs failed:", error); console.error("[API ERROR] Stack:", error?.stack); return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 }); diff --git a/src/app/api/usage/stats/route.js b/src/app/api/usage/stats/route.js index 27e51090..64a2bad1 100644 --- a/src/app/api/usage/stats/route.js +++ b/src/app/api/usage/stats/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getUsageStats } from "@/lib/usageDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d", "all"]); @@ -7,6 +8,7 @@ export const dynamic = "force-dynamic"; export async function GET(request) { try { + const user = await requireUsageDashboardUser(); const { searchParams } = new URL(request.url); const period = searchParams.get("period") || "7d"; @@ -14,9 +16,10 @@ export async function GET(request) { return NextResponse.json({ error: "Invalid period" }, { status: 400 }); } - const stats = await getUsageStats(period); + const stats = await getUsageStats(period, user); return NextResponse.json(stats); } catch (error) { + if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); console.error("[API] Failed to get usage stats:", error); return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 }); } diff --git a/src/app/api/usage/stream/route.js b/src/app/api/usage/stream/route.js index 57782dd3..6e24f879 100644 --- a/src/app/api/usage/stream/route.js +++ b/src/app/api/usage/stream/route.js @@ -1,8 +1,16 @@ import { getUsageStats, statsEmitter, getActiveRequests } from "@/lib/usageDb"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; export const dynamic = "force-dynamic"; export async function GET() { + let user; + try { + user = await requireUsageDashboardUser(); + } catch (error) { + if (error?.message === "Unauthorized") return Response.json({ error: "Unauthorized" }, { status: 401 }); + return Response.json({ error: "Failed to authorize usage stream" }, { status: 500 }); + } const encoder = new TextEncoder(); const state = { closed: false, keepalive: null, send: null, sendPending: null, cachedStats: null }; @@ -14,12 +22,12 @@ export async function GET() { try { // Push lightweight update immediately so UI reflects changes fast if (state.cachedStats) { - const { activeRequests, recentRequests, errorProvider } = await getActiveRequests(); + const { activeRequests, recentRequests, errorProvider } = await getActiveRequests(user); const quickStats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(quickStats)}\n\n`)); } // Then do full recalc and update cache - const stats = await getUsageStats(); + const stats = await getUsageStats("all", user); state.cachedStats = stats; controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`)); } catch { @@ -34,7 +42,7 @@ export async function GET() { state.sendPending = async () => { if (state.closed || !state.cachedStats) return; try { - const { activeRequests, recentRequests, errorProvider } = await getActiveRequests(); + const { activeRequests, recentRequests, errorProvider } = await getActiveRequests(user); const stats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`)); } catch { diff --git a/src/lib/auth/currentUser.js b/src/lib/auth/currentUser.js index 66ced082..a25f79ce 100644 --- a/src/lib/auth/currentUser.js +++ b/src/lib/auth/currentUser.js @@ -1,6 +1,6 @@ import { cookies } from "next/headers"; import { getDashboardAuthSession } from "./dashboardSession.js"; -import { getUserById, verifyUserPassword } from "@/lib/db"; +import { getSettings, getUserById, verifyUserPassword } from "@/lib/db"; export async function getCurrentDashboardUser() { const cookieStore = await cookies(); @@ -22,6 +22,20 @@ export async function requireCurrentDashboardUser() { return user; } +/** + * Resolve the user for dashboard data that is also available in the explicit + * single-user (`requireLogin=false`) deployment mode. That mode has no account + * boundary, so it intentionally uses the system-wide administrator scope. + */ +export async function requireUsageDashboardUser() { + const user = await getCurrentDashboardUser(); + if (user) return user; + + const settings = await getSettings(); + if (settings?.requireLogin === false) return { id: null, username: "local", role: "admin" }; + throw new Error("Unauthorized"); +} + export async function requireAdminUser() { const user = await requireCurrentDashboardUser(); if (user.role !== "admin") throw new Error("Forbidden"); diff --git a/src/lib/db/repos/requestDetailsRepo.js b/src/lib/db/repos/requestDetailsRepo.js index defd294b..e2ae14a0 100644 --- a/src/lib/db/repos/requestDetailsRepo.js +++ b/src/lib/db/repos/requestDetailsRepo.js @@ -1,5 +1,6 @@ import { getAdapter } from "../driver.js"; import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; +import { appendUsageAccessClause, getUsageAccessScope } from "./usageAccessScope.js"; const DEFAULT_MAX_RECORDS = 200; const DEFAULT_BATCH_SIZE = 20; @@ -142,8 +143,9 @@ export async function saveRequestDetail(detail) { } } -export async function getRequestDetails(filter = {}) { +export async function getRequestDetails(filter = {}, user = null) { const db = await getAdapter(); + const scope = await getUsageAccessScope(user); const conds = []; const params = []; @@ -153,6 +155,7 @@ export async function getRequestDetails(filter = {}) { if (filter.status) { conds.push("status = ?"); params.push(filter.status); } if (filter.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); } if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).toISOString()); } + appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null }); const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; const cntRow = db.get(`SELECT COUNT(*) as c FROM requestDetails ${where}`, params); @@ -175,15 +178,23 @@ export async function getRequestDetails(filter = {}) { }; } -export async function getDistinctProviders() { +export async function getDistinctProviders(user = null) { const db = await getAdapter(); - const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE provider IS NOT NULL ORDER BY provider ASC`); + const scope = await getUsageAccessScope(user); + const conds = ["provider IS NOT NULL"]; + const params = []; + appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null }); + const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE ${conds.join(" AND ")} ORDER BY provider ASC`, params); return rows.map((r) => r.provider); } -export async function getRequestDetailById(id) { +export async function getRequestDetailById(id, user = null) { const db = await getAdapter(); - const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]); + const scope = await getUsageAccessScope(user); + const conds = ["id = ?"]; + const params = [id]; + appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null }); + const row = db.get(`SELECT data FROM requestDetails WHERE ${conds.join(" AND ")}`, params); return row ? parseJson(row.data, null) : null; } diff --git a/src/lib/db/repos/usageAccessScope.js b/src/lib/db/repos/usageAccessScope.js new file mode 100644 index 00000000..df53c275 --- /dev/null +++ b/src/lib/db/repos/usageAccessScope.js @@ -0,0 +1,61 @@ +import { getAdapter } from "../driver.js"; + +/** + * Resolve the ownership boundary used by usage and observability queries. + * + * Administrators have system-wide visibility. A normal user can only see a + * request when it is attributable to one of their provider connections or + * dashboard API keys. Requests without either association are deliberately + * excluded for normal users rather than being treated as shared usage. + */ +export async function getUsageAccessScope(user) { + if (user?.role === "admin") { + return { isAdmin: true, connectionIds: [], apiKeys: [] }; + } + + if (!user?.id) { + return { isAdmin: false, connectionIds: [], apiKeys: [] }; + } + + const db = await getAdapter(); + const connectionIds = db.all( + `SELECT id FROM providerConnections WHERE ownerId = ?`, + [user.id], + ).map((row) => row.id); + const apiKeys = db.all( + `SELECT key FROM apiKeys WHERE ownerId = ?`, + [user.id], + ).map((row) => row.key); + + return { isAdmin: false, connectionIds, apiKeys }; +} + +/** + * Add an ownership predicate to a SQL WHERE clause. + * + * @param {string[]} conditions SQL conditions to append to. + * @param {unknown[]} params Bound parameters corresponding to conditions. + * @param {{ isAdmin: boolean, connectionIds: string[], apiKeys: string[] }} scope + * @param {{ connectionColumn?: string, apiKeyColumn?: string | null }} options + */ +export function appendUsageAccessClause( + conditions, + params, + scope, + { connectionColumn = "connectionId", apiKeyColumn = "apiKey" } = {}, +) { + if (scope?.isAdmin) return; + + const ownershipConditions = []; + if (scope?.connectionIds?.length) { + ownershipConditions.push(`${connectionColumn} IN (${scope.connectionIds.map(() => "?").join(", ")})`); + params.push(...scope.connectionIds); + } + if (apiKeyColumn && scope?.apiKeys?.length) { + ownershipConditions.push(`${apiKeyColumn} IN (${scope.apiKeys.map(() => "?").join(", ")})`); + params.push(...scope.apiKeys); + } + + // A missing ownership relation must never grant access to system usage. + conditions.push(ownershipConditions.length ? `(${ownershipConditions.join(" OR ")})` : "1 = 0"); +} diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 6b1966cb..2a233906 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -2,6 +2,7 @@ import { EventEmitter } from "events"; import { getAdapter } from "../driver.js"; import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; import { getMeta, setMeta } from "../helpers/metaStore.js"; +import { appendUsageAccessClause, getUsageAccessScope } from "./usageAccessScope.js"; function maskApiKey(key) { if (!key || typeof key !== "string") return null; @@ -193,11 +194,14 @@ export function trackPendingRequest(model, provider, connectionId, started, erro scheduleStatsEvent("pending"); } -export async function getActiveRequests() { +export async function getActiveRequests(user = null) { const activeRequests = []; const connectionMap = await getConnectionMapCached(); + const scope = await getUsageAccessScope(user); + const allowedConnectionIds = new Set(scope.connectionIds); for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) { + if (!scope.isAdmin && !allowedConnectionIds.has(connectionId)) continue; for (const [modelKey, count] of Object.entries(models)) { if (count > 0) { const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`; @@ -214,6 +218,7 @@ export async function getActiveRequests() { await ensureRingInitialized(); const seen = new Set(); const recentRequests = [...recentRing.items] + .filter((entry) => scope.isAdmin || allowedConnectionIds.has(entry.connectionId) || scope.apiKeys.includes(entry.apiKey)) .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) .map((e) => { const t = e.tokens || {}; @@ -343,7 +348,148 @@ function loadDaysInRange(adapter, maxDays) { return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ?`, [cutoffKey]); } -export async function getUsageStats(period = "all") { +function createEmptyUsageStats() { + return { + totalRequests: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + totalCachedTokens: 0, + totalCost: 0, + byProvider: {}, + byModel: {}, + byAccount: {}, + byApiKey: {}, + byEndpoint: {}, + last10Minutes: Array.from({ length: 10 }, () => ({ requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 })), + pending: { byModel: {}, byAccount: {} }, + activeRequests: [], + recentRequests: [], + errorProvider: "", + }; +} + +function getUsagePeriodCutoff(period) { + if (period === "today") { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + return startOfDay.toISOString(); + } + if (period === "24h") return new Date(Date.now() - PERIOD_MS["24h"]).toISOString(); + const days = { "7d": 7, "30d": 30, "60d": 60 }[period]; + if (!days) return null; + const startOfRange = new Date(); + startOfRange.setHours(0, 0, 0, 0); + startOfRange.setDate(startOfRange.getDate() - days + 1); + return startOfRange.toISOString(); +} + +async function getScopedUsageStats(period, user, scope) { + const db = await getAdapter(); + const [{ getProviderConnections }, { getApiKeys }, { getProviderNodes }] = await Promise.all([ + import("./connectionsRepo.js"), + import("./apiKeysRepo.js"), + import("./nodesRepo.js"), + ]); + const [connections, apiKeys, providerNodes] = await Promise.all([ + getProviderConnections({ ownerId: user.id }), + getApiKeys(), + getProviderNodes(), + ]); + const connectionMap = Object.fromEntries(connections.map((connection) => [connection.id, connection.name || connection.email || connection.id])); + const apiKeyMap = Object.fromEntries(apiKeys.map((key) => [key.key, key])); + const providerNodeNameMap = Object.fromEntries(providerNodes.filter((node) => node.id && node.name).map((node) => [node.id, node.name])); + const stats = createEmptyUsageStats(); + const conds = []; + const params = []; + const cutoff = getUsagePeriodCutoff(period); + if (cutoff) { conds.push("timestamp >= ?"); params.push(cutoff); } + appendUsageAccessClause(conds, params, scope); + const rows = db.all( + `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens FROM usageHistory WHERE ${conds.join(" AND ")} ORDER BY id DESC`, + params, + ); + const currentMinuteStart = Math.floor(Date.now() / 60000) * 60000; + const minuteBuckets = new Map(stats.last10Minutes.map((bucket, index) => [currentMinuteStart - (9 - index) * 60000, bucket])); + + for (const row of rows) { + const tokens = parseJson(row.tokens, {}) || {}; + const promptTokens = row.promptTokens || tokens.prompt_tokens || tokens.input_tokens || 0; + const completionTokens = row.completionTokens || tokens.completion_tokens || tokens.output_tokens || 0; + const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0; + const cost = row.cost || 0; + const provider = row.provider || "unknown"; + const providerDisplayName = providerNodeNameMap[provider] || provider; + + stats.totalRequests++; + stats.totalPromptTokens += promptTokens; + stats.totalCompletionTokens += completionTokens; + stats.totalCachedTokens += cachedTokens; + stats.totalCost += cost; + addToCounter(stats.byProvider, provider, { promptTokens, completionTokens, cachedTokens, cost }); + + const modelKey = `${row.model} (${provider})`; + addToCounter(stats.byModel, modelKey, { promptTokens, completionTokens, cachedTokens, cost, meta: { rawModel: row.model, provider: providerDisplayName, lastUsed: row.timestamp } }); + if (new Date(row.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = row.timestamp; + + if (row.connectionId && connectionMap[row.connectionId]) { + const accountName = connectionMap[row.connectionId]; + const accountKey = `${row.model} (${provider} - ${accountName})`; + addToCounter(stats.byAccount, accountKey, { promptTokens, completionTokens, cachedTokens, cost, meta: { rawModel: row.model, provider: providerDisplayName, connectionId: row.connectionId, accountName, lastUsed: row.timestamp } }); + if (new Date(row.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = row.timestamp; + } + + // A request may be visible through an owned connection even when its API + // key belongs to another account. Do not expose that key's name or mask. + if (!row.apiKey || scope.apiKeys.includes(row.apiKey)) { + const keyInfo = row.apiKey ? apiKeyMap[row.apiKey] : null; + const apiKeyMasked = maskApiKey(row.apiKey); + const apiKeyKey = row.apiKey ? `${apiKeyMasked}|${row.model}|${provider}` : "local-no-key"; + addToCounter(stats.byApiKey, apiKeyKey, { promptTokens, completionTokens, cachedTokens, cost, meta: { rawModel: row.model, provider: providerDisplayName, apiKeyMasked, keyName: keyInfo?.name || apiKeyMasked || "Local (No API Key)", apiKeyKey: apiKeyMasked || "local-no-key", lastUsed: row.timestamp } }); + if (new Date(row.timestamp) > new Date(stats.byApiKey[apiKeyKey].lastUsed)) stats.byApiKey[apiKeyKey].lastUsed = row.timestamp; + } + + const endpoint = row.endpoint || "Unknown"; + const endpointKey = `${endpoint}|${row.model}|${provider}`; + addToCounter(stats.byEndpoint, endpointKey, { promptTokens, completionTokens, cachedTokens, cost, meta: { endpoint, rawModel: row.model, provider: providerDisplayName, lastUsed: row.timestamp } }); + if (new Date(row.timestamp) > new Date(stats.byEndpoint[endpointKey].lastUsed)) stats.byEndpoint[endpointKey].lastUsed = row.timestamp; + + const minuteBucket = minuteBuckets.get(Math.floor(new Date(row.timestamp).getTime() / 60000) * 60000); + if (minuteBucket) { + minuteBucket.requests++; + minuteBucket.promptTokens += promptTokens; + minuteBucket.completionTokens += completionTokens; + minuteBucket.cost += cost; + } + } + + const seen = new Set(); + stats.recentRequests = rows.map((row) => { + const tokens = parseJson(row.tokens, {}) || {}; + return { timestamp: row.timestamp, model: row.model, provider: row.provider || "", promptTokens: row.promptTokens || tokens.prompt_tokens || tokens.input_tokens || 0, completionTokens: row.completionTokens || tokens.completion_tokens || tokens.output_tokens || 0, cachedTokens: tokens.cached_tokens || tokens.cache_read_input_tokens || 0, status: row.status || "ok" }; + }).filter((entry) => { + if (!entry.promptTokens && !entry.completionTokens) return false; + const key = `${entry.model}|${entry.provider}|${entry.promptTokens}|${entry.completionTokens}|${entry.timestamp?.slice(0, 16) || ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }).slice(0, 20); + + const active = await getActiveRequests(user); + stats.activeRequests = active.activeRequests; + stats.errorProvider = active.errorProvider; + for (const connectionId of scope.connectionIds) { + if (!pendingRequests.byAccount[connectionId]) continue; + stats.pending.byAccount[connectionId] = { ...pendingRequests.byAccount[connectionId] }; + for (const [model, count] of Object.entries(pendingRequests.byAccount[connectionId])) { + stats.pending.byModel[model] = (stats.pending.byModel[model] || 0) + count; + } + } + return stats; +} + +export async function getUsageStats(period = "all", user = null) { + const scope = await getUsageAccessScope(user); + if (!scope.isAdmin) return getScopedUsageStats(period, user, scope); const db = await getAdapter(); const [{ getProviderConnections }, { getApiKeys }, { getProviderNodes }] = await Promise.all([ @@ -658,8 +804,45 @@ export async function getUsageStats(period = "all") { return stats; } -export async function getChartData(period = "7d") { +function buildChartDataFromRows(rows, period) { + const now = Date.now(); + const isHourly = period === "today" || period === "24h"; + const bucketCount = isHourly ? 24 : period === "7d" ? 7 : period === "30d" ? 30 : 60; + const bucketMs = isHourly ? 3600000 : 86400000; + const startTime = period === "today" + ? new Date(new Date().setHours(0, 0, 0, 0)).getTime() + : isHourly ? now - bucketCount * bucketMs : new Date(new Date().setHours(0, 0, 0, 0) - (bucketCount - 1) * bucketMs).getTime(); + const labelFn = isHourly + ? (timestamp) => new Date(timestamp).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) + : (timestamp) => new Date(timestamp).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const buckets = Array.from({ length: bucketCount }, (_, index) => ({ label: labelFn(startTime + index * bucketMs), tokens: 0, cost: 0 })); + + for (const row of rows) { + const timestamp = new Date(row.timestamp).getTime(); + if (timestamp < startTime || timestamp > now) continue; + const index = Math.min(Math.floor((timestamp - startTime) / bucketMs), bucketCount - 1); + if (index < 0 || index >= bucketCount) continue; + buckets[index].tokens += (row.promptTokens || 0) + (row.completionTokens || 0); + buckets[index].cost += row.cost || 0; + } + return buckets; +} + +export async function getChartData(period = "7d", user = null) { const db = await getAdapter(); + const scope = await getUsageAccessScope(user); + if (!scope.isAdmin) { + const conds = []; + const params = []; + const cutoff = getUsagePeriodCutoff(period); + if (cutoff) { conds.push("timestamp >= ?"); params.push(cutoff); } + appendUsageAccessClause(conds, params, scope); + const rows = db.all( + `SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE ${conds.join(" AND ")}`, + params, + ); + return buildChartDataFromRows(rows, period); + } const now = Date.now(); if (period === "today") { @@ -739,12 +922,17 @@ function formatLogDate(date = new Date()) { // No-op: request log is now derived from usageHistory table on read. export async function appendRequestLog() {} -export async function getRecentLogs(limit = 200) { +export async function getRecentLogs(limit = 200, user = null) { try { const db = await getAdapter(); + const scope = await getUsageAccessScope(user); + const conds = []; + const params = []; + appendUsageAccessClause(conds, params, scope); + const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; const rows = db.all( - `SELECT timestamp, provider, model, connectionId, promptTokens, completionTokens, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`, - [limit], + `SELECT timestamp, provider, model, connectionId, promptTokens, completionTokens, status, tokens FROM usageHistory ${where} ORDER BY id DESC LIMIT ?`, + [...params, limit], ); if (!rows.length) return []; diff --git a/tests/unit/usage-access-scope.test.js b/tests/unit/usage-access-scope.test.js new file mode 100644 index 00000000..9c13915e --- /dev/null +++ b/tests/unit/usage-access-scope.test.js @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { appendUsageAccessClause } from "../../src/lib/db/repos/usageAccessScope.js"; + +describe("usage access scope SQL predicates", () => { + it("does not restrict administrators", () => { + const conditions = ["timestamp >= ?"]; + const params = ["2026-01-01T00:00:00.000Z"]; + + appendUsageAccessClause(conditions, params, { + isAdmin: true, + connectionIds: [], + apiKeys: [], + }); + + expect(conditions).toEqual(["timestamp >= ?"]); + expect(params).toEqual(["2026-01-01T00:00:00.000Z"]); + }); + + it("limits users to their owned connections and API keys", () => { + const conditions = []; + const params = []; + + appendUsageAccessClause(conditions, params, { + isAdmin: false, + connectionIds: ["connection-a", "connection-b"], + apiKeys: ["key-a"], + }); + + expect(conditions).toEqual(["(connectionId IN (?, ?) OR apiKey IN (?))"]); + expect(params).toEqual(["connection-a", "connection-b", "key-a"]); + }); + + it("denies users with no attributable resources", () => { + const conditions = []; + const params = []; + + appendUsageAccessClause(conditions, params, { + isAdmin: false, + connectionIds: [], + apiKeys: [], + }); + + expect(conditions).toEqual(["1 = 0"]); + expect(params).toEqual([]); + }); + + it("can scope request details by connection only", () => { + const conditions = []; + const params = []; + + appendUsageAccessClause( + conditions, + params, + { isAdmin: false, connectionIds: ["connection-a"], apiKeys: ["key-a"] }, + { apiKeyColumn: null }, + ); + + expect(conditions).toEqual(["(connectionId IN (?))"]); + expect(params).toEqual(["connection-a"]); + }); +});