Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions auth-server/src/app/api/admin/send-daily-report/buildReportHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,6 +14,8 @@ interface BuildReportOpts {
newOrganizations: readonly OrganizationDefinition[];
newErrors: readonly ErrorRow[];
topMostActiveUsers: readonly TopMostActiveUserRow[];
topMostPopularApps: readonly TopMostPopularAppRow[];
topMostPopularApis: readonly TopMostPopularApiRow[];
}

interface ReportContent {
Expand Down Expand Up @@ -46,6 +50,8 @@ export function buildDailyAdminReport({
newOrganizations,
newErrors,
topMostActiveUsers,
topMostPopularApps,
topMostPopularApis,
}: BuildReportOpts): ReportContent {
const windowLabel = `${formatTimestamp(windowStart.getTime())} → ${formatTimestamp(windowEnd.getTime())}`;

Expand Down Expand Up @@ -91,6 +97,34 @@ export function buildDailyAdminReport({
})
.join("\n");

const topPopularAppsRows = topMostPopularApps.length === 0
? `<tr><td colspan="5" style="padding:12px;color:${MUTED_COLOR};font-style:italic;">No application token activity in the last 24 hours.</td></tr>`
: topMostPopularApps
.map((a, i) => {
return `<tr>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};color:${TEXT_COLOR};font-weight:600;width:48px;">#${i + 1}</td>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};color:${TEXT_COLOR};">${escapeHtml(a.app_name)}</td>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};font-family:monospace;font-size:12px;color:${MUTED_COLOR};">${escapeHtml(a.client_app_id)}</td>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};color:${TEXT_COLOR};font-weight:600;text-align:right;">${a.access_token_count.toLocaleString("en-US")}</td>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};color:${TEXT_COLOR};font-weight:600;text-align:right;">${a.refresh_token_count.toLocaleString("en-US")}</td>
</tr>`;
})
.join("\n");

const topPopularApisRows = topMostPopularApis.length === 0
? `<tr><td colspan="5" style="padding:12px;color:${MUTED_COLOR};font-style:italic;">No API token activity in the last 24 hours.</td></tr>`
: topMostPopularApis
.map((a, i) => {
return `<tr>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};color:${TEXT_COLOR};font-weight:600;width:48px;">#${i + 1}</td>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};color:${TEXT_COLOR};">${escapeHtml(a.api_server_name)}</td>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};font-family:monospace;font-size:12px;color:${MUTED_COLOR};">${escapeHtml(a.api_server_id)}</td>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};color:${TEXT_COLOR};font-weight:600;text-align:right;">${a.access_token_count.toLocaleString("en-US")}</td>
<td style="padding:8px 12px;border-bottom:1px solid ${BORDER_COLOR};color:${TEXT_COLOR};font-weight:600;text-align:right;">${a.refresh_token_count.toLocaleString("en-US")}</td>
</tr>`;
})
.join("\n");

const errorsRows = newErrors.length === 0
? `<tr><td colspan="4" style="padding:12px;color:${MUTED_COLOR};font-style:italic;">No new errors in the last 24 hours.</td></tr>`
: newErrors
Expand Down Expand Up @@ -170,6 +204,44 @@ ${topMostActiveRows}
</table>
</td>
</tr>
<tr>
<td style="padding:8px 32px 24px;">
<h2 style="margin:0 0 12px;font-size:16px;color:${BRAND_BLUE};">Most popular applications (${topMostPopularApps.length})</h2>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<thead>
<tr>
<th align="left" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Rank</th>
<th align="left" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Application</th>
<th align="left" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Client app ID</th>
<th align="right" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Access tokens</th>
<th align="right" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Refresh tokens</th>
</tr>
</thead>
<tbody>
${topPopularAppsRows}
</tbody>
</table>
</td>
</tr>
<tr>
<td style="padding:8px 32px 24px;">
<h2 style="margin:0 0 12px;font-size:16px;color:${BRAND_BLUE};">Most popular APIs (${topMostPopularApis.length})</h2>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<thead>
<tr>
<th align="left" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Rank</th>
<th align="left" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">API</th>
<th align="left" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Audience (API server ID)</th>
<th align="right" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Access tokens</th>
<th align="right" style="padding:8px 12px;border-bottom:2px solid ${BRAND_BLUE};color:${TEXT_COLOR};font-size:12px;text-transform:uppercase;letter-spacing:0.05em;">Refresh tokens</th>
</tr>
</thead>
<tbody>
${topPopularApisRows}
</tbody>
</table>
</td>
</tr>
<tr>
<td style="padding:8px 32px 24px;">
<h2 style="margin:0 0 12px;font-size:16px;color:${BRAND_RED};">New errors (${newErrors.length})</h2>
Expand Down Expand Up @@ -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)");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand All @@ -53,6 +63,8 @@ export async function sendDailyReportHandler({
newOrganizations,
newErrors,
topMostActiveUsers,
topMostPopularApps,
topMostPopularApis,
});

const dateLabel = windowEnd.toISOString().slice(0, 10);
Expand All @@ -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(),
});
Expand Down
3 changes: 3 additions & 0 deletions auth-server/src/lib/auth-db/apis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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<AuthDatabase> | Transaction<AuthDatabase>,
since_ms: number,
limit: number = 10,
): Promise<readonly TopMostPopularApiRow[]> {
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<string, string>();
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;
2 changes: 2 additions & 0 deletions auth-server/src/lib/auth-db/apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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<AuthDatabase> | Transaction<AuthDatabase>,
since_ms: number,
limit: number = 10,
): Promise<readonly TopMostPopularAppRow[]> {
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<string, string>();
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;
Loading
Loading