From 0c242d0d8d5f9ccd1ad7bed9501b2c43fb7f95f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 16:45:26 +0000 Subject: [PATCH] auth-server:0.27.14 - add most-popular apps & APIs to daily admin report Summarize which client applications and APIs are driving token usage. Adds "Most popular applications" (grouped by issued_tokens.client_app_id, ranked by total access + refresh tokens) and "Most popular APIs" (grouped by token audience, ranked by access tokens) sections to both the HTML and plain-text daily admin report, mirroring the existing top most-active users section. https://claude.ai/code/session_015YubdbmA5Vtzi2ydEJNqmt --- auth-server/package.json | 2 +- .../send-daily-report/buildReportHtml.ts | 94 +++++++++++++++++++ .../sendDailyReportHandler.ts | 28 ++++-- auth-server/src/lib/auth-db/apis/index.ts | 3 + .../apis/list-top-most-popular-apis-since.ts | 88 +++++++++++++++++ auth-server/src/lib/auth-db/apps/index.ts | 2 + .../apps/list-top-most-popular-apps-since.ts | 79 ++++++++++++++++ ...t-tokens-issued-since-grouped-by-column.ts | 60 ++++++++++++ .../src/lib/auth-db/issued-tokens/index.ts | 2 + 9 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 auth-server/src/lib/auth-db/apis/list-top-most-popular-apis-since.ts create mode 100644 auth-server/src/lib/auth-db/apps/list-top-most-popular-apps-since.ts create mode 100644 auth-server/src/lib/auth-db/issued-tokens/count-tokens-issued-since-grouped-by-column.ts diff --git a/auth-server/package.json b/auth-server/package.json index ba3f04ca..f8256575 100644 --- a/auth-server/package.json +++ b/auth-server/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/auth-server", - "version": "0.27.13", + "version": "0.27.14", "private": true, "repository": { "type": "git", diff --git a/auth-server/src/app/api/admin/send-daily-report/buildReportHtml.ts b/auth-server/src/app/api/admin/send-daily-report/buildReportHtml.ts index 239a26f9..24ec2b43 100644 --- a/auth-server/src/app/api/admin/send-daily-report/buildReportHtml.ts +++ b/auth-server/src/app/api/admin/send-daily-report/buildReportHtml.ts @@ -3,6 +3,8 @@ import { brandColors } from "@schemavaults/theme"; import type { OrganizationDefinition } from "@schemavaults/auth-common"; import type { TopMostActiveUserRow, UserDocument } from "@/lib/auth-db/users"; import type { ErrorRow } from "@/lib/auth-db/errors"; +import type { TopMostPopularAppRow } from "@/lib/auth-db/apps"; +import type { TopMostPopularApiRow } from "@/lib/auth-db/apis"; interface BuildReportOpts { authServerUri: string; @@ -12,6 +14,8 @@ interface BuildReportOpts { newOrganizations: readonly OrganizationDefinition[]; newErrors: readonly ErrorRow[]; topMostActiveUsers: readonly TopMostActiveUserRow[]; + topMostPopularApps: readonly TopMostPopularAppRow[]; + topMostPopularApis: readonly TopMostPopularApiRow[]; } interface ReportContent { @@ -46,6 +50,8 @@ export function buildDailyAdminReport({ newOrganizations, newErrors, topMostActiveUsers, + topMostPopularApps, + topMostPopularApis, }: BuildReportOpts): ReportContent { const windowLabel = `${formatTimestamp(windowStart.getTime())} → ${formatTimestamp(windowEnd.getTime())}`; @@ -91,6 +97,34 @@ export function buildDailyAdminReport({ }) .join("\n"); + const topPopularAppsRows = topMostPopularApps.length === 0 + ? `No application token activity in the last 24 hours.` + : topMostPopularApps + .map((a, i) => { + return ` + #${i + 1} + ${escapeHtml(a.app_name)} + ${escapeHtml(a.client_app_id)} + ${a.access_token_count.toLocaleString("en-US")} + ${a.refresh_token_count.toLocaleString("en-US")} +`; + }) + .join("\n"); + + const topPopularApisRows = topMostPopularApis.length === 0 + ? `No API token activity in the last 24 hours.` + : topMostPopularApis + .map((a, i) => { + return ` + #${i + 1} + ${escapeHtml(a.api_server_name)} + ${escapeHtml(a.api_server_id)} + ${a.access_token_count.toLocaleString("en-US")} + ${a.refresh_token_count.toLocaleString("en-US")} +`; + }) + .join("\n"); + const errorsRows = newErrors.length === 0 ? `No new errors in the last 24 hours.` : newErrors @@ -170,6 +204,44 @@ ${topMostActiveRows} + + +

Most popular applications (${topMostPopularApps.length})

+ + + + + + + + + + + +${topPopularAppsRows} + +
RankApplicationClient app IDAccess tokensRefresh tokens
+ + + + +

Most popular APIs (${topMostPopularApis.length})

+ + + + + + + + + + + +${topPopularApisRows} + +
RankAPIAudience (API server ID)Access tokensRefresh tokens
+ +

New errors (${newErrors.length})

@@ -229,6 +301,28 @@ ${errorsRows} }); } textLines.push(""); + textLines.push(`Most popular applications (${topMostPopularApps.length}):`); + if (topMostPopularApps.length === 0) { + textLines.push(" (none)"); + } else { + topMostPopularApps.forEach((a, i) => { + textLines.push( + ` ${i + 1}. ${a.app_name} [${a.client_app_id}] — ${a.access_token_count.toLocaleString("en-US")} access / ${a.refresh_token_count.toLocaleString("en-US")} refresh`, + ); + }); + } + textLines.push(""); + textLines.push(`Most popular APIs (${topMostPopularApis.length}):`); + if (topMostPopularApis.length === 0) { + textLines.push(" (none)"); + } else { + topMostPopularApis.forEach((a, i) => { + textLines.push( + ` ${i + 1}. ${a.api_server_name} [${a.api_server_id}] — ${a.access_token_count.toLocaleString("en-US")} access / ${a.refresh_token_count.toLocaleString("en-US")} refresh`, + ); + }); + } + textLines.push(""); textLines.push(`New errors (${newErrors.length}):`); if (newErrors.length === 0) { textLines.push(" (none)"); diff --git a/auth-server/src/app/api/admin/send-daily-report/sendDailyReportHandler.ts b/auth-server/src/app/api/admin/send-daily-report/sendDailyReportHandler.ts index 91ab3b77..b7766bc4 100644 --- a/auth-server/src/app/api/admin/send-daily-report/sendDailyReportHandler.ts +++ b/auth-server/src/app/api/admin/send-daily-report/sendDailyReportHandler.ts @@ -12,6 +12,8 @@ import { } from "@/lib/auth-db/users"; import { listErrorsCreatedSince } from "@/lib/auth-db/errors"; import { listOrganizationsCreatedSince } from "@/lib/auth-db/organizations"; +import { listTopMostPopularAppsSince } from "@/lib/auth-db/apps"; +import { listTopMostPopularApisSince } from "@/lib/auth-db/apis"; import sendEmailViaMailServer from "@/lib/send-email-via-mail-server"; import captureServerException from "@/lib/captureServerException"; import { buildDailyAdminReport } from "./buildReportHtml"; @@ -34,13 +36,21 @@ export async function sendDailyReportHandler({ const windowEnd = new Date(); const windowStart = new Date(windowEnd.getTime() - TWENTY_FOUR_HOURS_MS); - const [newUsers, newOrganizations, newErrors, topMostActiveUsers] = - await Promise.all([ - listUsersCreatedSince(dbh.db, windowStart.getTime()), - listOrganizationsCreatedSince(dbh.db, windowStart.getTime()), - listErrorsCreatedSince(dbh.db, windowStart.getTime()), - listTopMostActiveUsersSince(dbh.db, windowStart.getTime(), 10), - ]); + const [ + newUsers, + newOrganizations, + newErrors, + topMostActiveUsers, + topMostPopularApps, + topMostPopularApis, + ] = await Promise.all([ + listUsersCreatedSince(dbh.db, windowStart.getTime()), + listOrganizationsCreatedSince(dbh.db, windowStart.getTime()), + listErrorsCreatedSince(dbh.db, windowStart.getTime()), + listTopMostActiveUsersSince(dbh.db, windowStart.getTime(), 10), + listTopMostPopularAppsSince(dbh.db, windowStart.getTime(), 10), + listTopMostPopularApisSince(dbh.db, windowStart.getTime(), 10), + ]); const appEnv: SchemaVaultsAppEnvironment = getAppEnvironment(); const authServerUri: string = getAuthServerUri(appEnv); @@ -53,6 +63,8 @@ export async function sendDailyReportHandler({ newOrganizations, newErrors, topMostActiveUsers, + topMostPopularApps, + topMostPopularApis, }); const dateLabel = windowEnd.toISOString().slice(0, 10); @@ -72,6 +84,8 @@ export async function sendDailyReportHandler({ organizations_count: newOrganizations.length, errors_count: newErrors.length, top_most_active_users_count: topMostActiveUsers.length, + top_most_popular_apps_count: topMostPopularApps.length, + top_most_popular_apis_count: topMostPopularApis.length, window_start: windowStart.toISOString(), window_end: windowEnd.toISOString(), }); diff --git a/auth-server/src/lib/auth-db/apis/index.ts b/auth-server/src/lib/auth-db/apis/index.ts index d854aafe..084b96c5 100644 --- a/auth-server/src/lib/auth-db/apis/index.ts +++ b/auth-server/src/lib/auth-db/apis/index.ts @@ -12,3 +12,6 @@ export { preloadApiServersTable } from './preload-api-servers-table'; export type { QueryApiServersInputOptions } from './preload-api-servers-table'; export { default as loadApiServerDefinitionFromDatabase } from './load-api-server-definition-from-db'; + +export { listTopMostPopularApisSince } from './list-top-most-popular-apis-since'; +export type { TopMostPopularApiRow } from './list-top-most-popular-apis-since'; diff --git a/auth-server/src/lib/auth-db/apis/list-top-most-popular-apis-since.ts b/auth-server/src/lib/auth-db/apis/list-top-most-popular-apis-since.ts new file mode 100644 index 00000000..c45eb5cd --- /dev/null +++ b/auth-server/src/lib/auth-db/apis/list-top-most-popular-apis-since.ts @@ -0,0 +1,88 @@ +import "server-only"; +import type { Kysely, Transaction } from "@schemavaults/dbh"; +import { + isHardcodedApiServerId, + getHardcodedApiServer, +} from "@schemavaults/app-definitions"; +import type { AuthDatabase } from "@/lib/auth-db/auth-database-types"; +import { countTokensIssuedSinceGroupedByColumn } from "@/lib/auth-db/issued-tokens"; + +export interface TopMostPopularApiRow { + api_server_id: string; + api_server_name: string; + access_token_count: number; + refresh_token_count: number; +} + +/** + * List the API servers (token audiences) that received the most tokens since + * `since_ms`, ranked by access-token count. + * + * Note: refresh tokens are always issued with the auth server itself as their + * audience, so ranking by access tokens keeps the list reflective of actual + * resource-API usage rather than refresh-token volume. The refresh count is + * still surfaced per row for transparency. + */ +export async function listTopMostPopularApisSince( + db: Kysely | Transaction, + since_ms: number, + limit: number = 10, +): Promise { + const countsByAudience = await countTokensIssuedSinceGroupedByColumn( + db, + since_ms, + "audience", + ); + + const ranked = Array.from(countsByAudience.entries()) + .map(([api_server_id, c]) => ({ + api_server_id, + access: c.access, + refresh: c.refresh, + })) + .sort((x, y) => { + if (y.access !== x.access) return y.access - x.access; + if (y.refresh !== x.refresh) return y.refresh - x.refresh; + return x.api_server_id < y.api_server_id + ? -1 + : x.api_server_id > y.api_server_id + ? 1 + : 0; + }) + .slice(0, limit); + + if (ranked.length === 0) return []; + + // Resolve human-readable names for the top API servers only. + const nameById = new Map(); + const dbApiServerIds: string[] = []; + for (const r of ranked) { + if (isHardcodedApiServerId(r.api_server_id)) { + nameById.set( + r.api_server_id, + getHardcodedApiServer(r.api_server_id).api_server_name, + ); + } else { + dbApiServerIds.push(r.api_server_id); + } + } + if (dbApiServerIds.length > 0) { + const apiRows = await db + .selectFrom("api_servers") + .where("api_server_id", "in", dbApiServerIds) + .select(["api_server_id", "api_server_name"]) + .execute(); + for (const row of apiRows) { + nameById.set(row.api_server_id, row.api_server_name); + } + } + + return ranked.map((r) => ({ + api_server_id: r.api_server_id, + api_server_name: nameById.get(r.api_server_id) ?? r.api_server_id, + access_token_count: r.access, + refresh_token_count: r.refresh, + })); +} + +export default listTopMostPopularApisSince; diff --git a/auth-server/src/lib/auth-db/apps/index.ts b/auth-server/src/lib/auth-db/apps/index.ts index 0f7fd67d..608b7a71 100644 --- a/auth-server/src/lib/auth-db/apps/index.ts +++ b/auth-server/src/lib/auth-db/apps/index.ts @@ -4,3 +4,5 @@ export { AuthorizedAppsRegistry } from './authorized-apps-registry'; export type * from './authorized-apps-registry'; export { getDefinitionForAuthorizedDeclaration } from './get-app-from-authorized-declaration'; export { preloadAppsTable } from './preload-apps-table'; +export { listTopMostPopularAppsSince } from './list-top-most-popular-apps-since'; +export type { TopMostPopularAppRow } from './list-top-most-popular-apps-since'; diff --git a/auth-server/src/lib/auth-db/apps/list-top-most-popular-apps-since.ts b/auth-server/src/lib/auth-db/apps/list-top-most-popular-apps-since.ts new file mode 100644 index 00000000..9dd30119 --- /dev/null +++ b/auth-server/src/lib/auth-db/apps/list-top-most-popular-apps-since.ts @@ -0,0 +1,79 @@ +import "server-only"; +import type { Kysely, Transaction } from "@schemavaults/dbh"; +import { isHardcodedAppId, getHardcodedApp } from "@schemavaults/app-definitions"; +import type { AuthDatabase } from "@/lib/auth-db/auth-database-types"; +import { countTokensIssuedSinceGroupedByColumn } from "@/lib/auth-db/issued-tokens"; + +export interface TopMostPopularAppRow { + client_app_id: string; + app_name: string; + access_token_count: number; + refresh_token_count: number; +} + +/** + * List the client applications that issued the most tokens since `since_ms`, + * ranked by total tokens (access + refresh). Both token types carry the real + * `client_app_id`, so both contribute to an app's popularity. + */ +export async function listTopMostPopularAppsSince( + db: Kysely | Transaction, + since_ms: number, + limit: number = 10, +): Promise { + const countsByAppId = await countTokensIssuedSinceGroupedByColumn( + db, + since_ms, + "client_app_id", + ); + + const ranked = Array.from(countsByAppId.entries()) + .map(([client_app_id, c]) => ({ + client_app_id, + access: c.access, + refresh: c.refresh, + total: c.access + c.refresh, + })) + .sort((x, y) => { + if (y.total !== x.total) return y.total - x.total; + if (y.access !== x.access) return y.access - x.access; + return x.client_app_id < y.client_app_id + ? -1 + : x.client_app_id > y.client_app_id + ? 1 + : 0; + }) + .slice(0, limit); + + if (ranked.length === 0) return []; + + // Resolve human-readable names for the top apps only. + const nameById = new Map(); + const dbAppIds: string[] = []; + for (const r of ranked) { + if (isHardcodedAppId(r.client_app_id)) { + nameById.set(r.client_app_id, getHardcodedApp(r.client_app_id).app_name); + } else { + dbAppIds.push(r.client_app_id); + } + } + if (dbAppIds.length > 0) { + const appRows = await db + .selectFrom("apps") + .where("app_id", "in", dbAppIds) + .select(["app_id", "app_name"]) + .execute(); + for (const row of appRows) { + nameById.set(row.app_id, row.app_name); + } + } + + return ranked.map((r) => ({ + client_app_id: r.client_app_id, + app_name: nameById.get(r.client_app_id) ?? r.client_app_id, + access_token_count: r.access, + refresh_token_count: r.refresh, + })); +} + +export default listTopMostPopularAppsSince; diff --git a/auth-server/src/lib/auth-db/issued-tokens/count-tokens-issued-since-grouped-by-column.ts b/auth-server/src/lib/auth-db/issued-tokens/count-tokens-issued-since-grouped-by-column.ts new file mode 100644 index 00000000..71b27316 --- /dev/null +++ b/auth-server/src/lib/auth-db/issued-tokens/count-tokens-issued-since-grouped-by-column.ts @@ -0,0 +1,60 @@ +import "server-only"; +import type { Kysely, Transaction } from "@schemavaults/dbh"; +import type { AuthDatabase } from "@/lib/auth-db/auth-database-types"; +import type { IssuedTokenCounts } from "./count-tokens-issued-since-grouped-by-uid"; + +/** + * Columns on `issued_tokens` that can be used to bucket token-issuance counts. + * `client_app_id` powers the "most popular applications" report section, while + * `audience` powers the "most popular APIs" section. + */ +export type GroupableTokenColumn = "client_app_id" | "audience"; + +function toNumber(raw: string | number | bigint): number { + if (typeof raw === "number") return raw; + if (typeof raw === "bigint") return Number(raw); + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) { + throw new Error(`Failed to parse token count as an integer: "${raw}"`); + } + return parsed; +} + +/** + * Count access/refresh tokens issued since `since_ms`, grouped by an arbitrary + * `issued_tokens` column (e.g. `client_app_id` or `audience`). + * + * @see countTokensIssuedSinceGroupedByUid The per-user variant used for the + * "top most-active users" section. + */ +export async function countTokensIssuedSinceGroupedByColumn( + db: Kysely | Transaction, + since_ms: number, + column: GroupableTokenColumn, +): Promise> { + const result = new Map(); + + const rows = await db + .selectFrom("issued_tokens") + .where("issued_at", ">", since_ms) + .select([column, "token_type"]) + .select((eb) => eb.fn.countAll().as("count")) + .groupBy([column, "token_type"]) + .execute(); + + for (const row of rows) { + const key = row[column] as string; + const existing = result.get(key) ?? { access: 0, refresh: 0 }; + const count = toNumber(row.count); + if (row.token_type === "access") { + existing.access = count; + } else if (row.token_type === "refresh") { + existing.refresh = count; + } + result.set(key, existing); + } + + return result; +} + +export default countTokensIssuedSinceGroupedByColumn; diff --git a/auth-server/src/lib/auth-db/issued-tokens/index.ts b/auth-server/src/lib/auth-db/issued-tokens/index.ts index 297f6972..be75ec59 100644 --- a/auth-server/src/lib/auth-db/issued-tokens/index.ts +++ b/auth-server/src/lib/auth-db/issued-tokens/index.ts @@ -5,6 +5,8 @@ export { countTokensCreatedByUser } from "./count-tokens-created-by-user"; export type { CreatedTokenCounts } from "./count-tokens-created-by-user"; export { countTokensIssuedSinceGroupedByUid } from "./count-tokens-issued-since-grouped-by-uid"; export type { IssuedTokenCounts } from "./count-tokens-issued-since-grouped-by-uid"; +export { countTokensIssuedSinceGroupedByColumn } from "./count-tokens-issued-since-grouped-by-column"; +export type { GroupableTokenColumn } from "./count-tokens-issued-since-grouped-by-column"; export { listIssuedTokensForUser } from "./list-issued-tokens-for-user"; export type { ListIssuedTokensForUserOptions } from "./list-issued-tokens-for-user"; export type * from "./issued-tokens-table";