Fix getTasks result validation + co-locate analytics types (#94)

This commit is contained in:
Chris Estreich 2025-06-10 13:54:41 -07:00 committed by GitHub
parent 4d40eb6e2b
commit ba7e9cd1a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 99 additions and 104 deletions

View file

@ -1,7 +1,6 @@
'use server';
import { z } from 'zod';
import { auth } from '@clerk/nextjs/server';
import {
type RooCodeTelemetryEvent,
@ -9,67 +8,12 @@ import {
} from '@roo-code/types';
import type { AnyTimePeriod } from '@/types';
import { taskSchema } from '@/types/analytics';
import { analytics } from '@/lib/server';
import { type User, getUsersById } from '@/db/server';
import { validateAnalyticsAccess } from '@/actions/auth';
type Table = 'events' | 'messages';
/**
* Validates authentication and authorization for analytics functions
*/
async function validateAnalyticsAccess({
requestedOrgId,
requestedUserId,
requireAdmin = false,
allowCrossUserAccess = false,
}: {
requestedOrgId?: string | null;
requestedUserId?: string | null;
requireAdmin?: boolean;
allowCrossUserAccess?: boolean;
}): Promise<{
authOrgId: string;
authUserId: string;
orgRole: string;
effectiveUserId: string | null;
}> {
const { orgId: authOrgId, orgRole, userId: authUserId } = await auth();
// Ensure user is authenticated and belongs to the organization
if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) {
throw new Error('Unauthorized: Invalid organization access');
}
// Check if admin access is required
if (requireAdmin && orgRole !== 'org:admin') {
throw new Error('Unauthorized: Administrator access required');
}
// If user is not an admin and trying to access data other than their own
if (
orgRole !== 'org:admin' &&
requestedUserId &&
requestedUserId !== authUserId
) {
throw new Error('Unauthorized: Members can only access their own data');
}
// For non-admin users, force userId filter to their own ID
// Unless allowCrossUserAccess is true and we're checking task sharing permissions
const effectiveUserId =
orgRole !== 'org:admin' && !allowCrossUserAccess
? authUserId
: requestedUserId || null;
return {
authOrgId,
authUserId,
orgRole: orgRole || 'unknown',
effectiveUserId,
};
}
/**
* captureEvent
*/
@ -344,13 +288,20 @@ export const getModelUsage = async ({
* getTasks
*/
const taskWithTitleSchema = taskSchema.extend({
const taskSchema = z.object({
taskId: z.string(),
userId: z.string(),
provider: z.string(),
title: z.string().nullable(),
mode: z.string().nullable(),
model: z.string(),
completed: z.coerce.boolean(),
tokens: z.coerce.number(),
cost: z.coerce.number(),
timestamp: z.coerce.number(),
});
export type TaskWithTitle = z.infer<typeof taskWithTitleSchema>;
export type TaskWithUser = TaskWithTitle & { user: User };
export type TaskWithUser = z.infer<typeof taskSchema> & { user: User };
export const getTasks = async ({
orgId,
@ -375,9 +326,11 @@ export const getTasks = async ({
const userFilter = effectiveUserId ? 'AND e.userId = {userId: String}' : '';
const taskFilter = taskId ? 'AND e.taskId = {taskId: String}' : '';
const messageUserFilter = effectiveUserId
? 'AND userId = {userId: String}'
: '';
const messageTaskFilter = taskId ? 'AND taskId = {taskId: String}' : '';
const queryParams: Record<string, string | string[]> = {
@ -388,9 +341,11 @@ export const getTasks = async ({
TelemetryEventName.LLM_COMPLETION,
],
};
if (effectiveUserId) {
queryParams.userId = effectiveUserId;
}
if (taskId) {
queryParams.taskId = taskId;
}
@ -424,6 +379,7 @@ export const getTasks = async ({
WHERE
e.orgId = {orgId: String}
AND e.type IN ({types: Array(String)})
AND e.modelId IS NOT NULL
${userFilter}
${taskFilter}
GROUP BY 1, 2
@ -433,7 +389,7 @@ export const getTasks = async ({
query_params: queryParams,
});
const tasks = z.array(taskWithTitleSchema).parse(await results.json());
const tasks = z.array(taskSchema).parse(await results.json());
const users = await getUsersById(tasks.map(({ userId }) => userId));

View file

@ -2,13 +2,30 @@
import { z } from 'zod';
import { messageSchema, type Message } from '@/types/analytics';
import { analytics } from '@/lib/server';
/**
* getMessages
*/
const messageSchema = z.object({
id: z.string(),
orgId: z.string(),
userId: z.string(),
taskId: z.string(),
mode: z.string().nullable(),
ts: z.number(),
type: z.enum(['ask', 'say']),
ask: z.string().nullable(),
say: z.string().nullable(),
text: z.string().nullable(),
reasoning: z.string().nullable(),
partial: z.boolean().nullable(),
timestamp: z.number(),
});
export type Message = z.infer<typeof messageSchema>;
export const getMessages = async (taskId: string): Promise<Message[]> => {
const results = await analytics.query({
query: `

View file

@ -21,6 +21,61 @@ export async function validateAuth(): Promise<
return { userId, orgId, orgRole: orgRole || 'unknown' };
}
/**
* Validates authentication and authorization for analytics functions.
*/
export async function validateAnalyticsAccess({
requestedOrgId,
requestedUserId,
requireAdmin = false,
allowCrossUserAccess = false,
}: {
requestedOrgId?: string | null;
requestedUserId?: string | null;
requireAdmin?: boolean;
allowCrossUserAccess?: boolean;
}): Promise<{
authOrgId: string;
authUserId: string;
orgRole: string;
effectiveUserId: string | null;
}> {
const { orgId: authOrgId, orgRole, userId: authUserId } = await auth();
// Ensure user is authenticated and belongs to the organization
if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) {
throw new Error('Unauthorized: Invalid organization access');
}
// Check if admin access is required
if (requireAdmin && orgRole !== 'org:admin') {
throw new Error('Unauthorized: Administrator access required');
}
// If user is not an admin and trying to access data other than their own
if (
orgRole !== 'org:admin' &&
requestedUserId &&
requestedUserId !== authUserId
) {
throw new Error('Unauthorized: Members can only access their own data');
}
// For non-admin users, force userId filter to their own ID
// Unless allowCrossUserAccess is true and we're checking task sharing permissions
const effectiveUserId =
orgRole !== 'org:admin' && !allowCrossUserAccess
? authUserId
: requestedUserId || null;
return {
authOrgId,
authUserId,
orgRole: orgRole || 'unknown',
effectiveUserId,
};
}
// Default expiration is 30 days (2592000 seconds).
export async function getSignInToken(
userId: string,

View file

@ -10,7 +10,6 @@ import {
shareIdSchema,
} from '@/types';
import type { SharedByUser } from '@/types/task-sharing';
import type { Message } from '@/types/analytics';
import { type TaskShare, AuditLogTargetType } from '@/db';
import { client as db, taskShares, users } from '@/db/server';
import { handleError, isAuthSuccess, generateShareToken } from '@/lib/server';
@ -21,7 +20,12 @@ import {
createShareUrl,
DEFAULT_SHARE_EXPIRATION_DAYS,
} from '@/lib/task-sharing';
import { type TaskWithUser, getTasks, getMessages } from '@/actions/analytics';
import {
type TaskWithUser,
getTasks,
type Message,
getMessages,
} from '@/actions/analytics';
import { validateAuth } from './auth';
import { insertAuditLog } from './auditLogs';

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import type { Message } from '@/types/analytics';
import type { Message } from '@/actions/analytics';
import { cn } from '@/lib/utils';
import { formatTimestamp } from '@/lib/formatters';

View file

@ -1,5 +1,4 @@
import type { TaskWithUser } from '@/actions/analytics';
import type { Message } from '@/types/analytics';
import type { TaskWithUser, Message } from '@/actions/analytics';
import type { SharedByUser } from '@/types/task-sharing';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import { generateFallbackTitle } from '@/lib/task-utils';

View file

@ -1,2 +0,0 @@
export * from './message';
export * from './task';

View file

@ -1,19 +0,0 @@
import { z } from 'zod';
export const messageSchema = z.object({
id: z.string(),
orgId: z.string(),
userId: z.string(),
taskId: z.string(),
mode: z.string().nullable(),
ts: z.number(),
type: z.enum(['ask', 'say']),
ask: z.string().nullable(),
say: z.string().nullable(),
text: z.string().nullable(),
reasoning: z.string().nullable(),
partial: z.boolean().nullable(),
timestamp: z.number(),
});
export type Message = z.infer<typeof messageSchema>;

View file

@ -1,15 +0,0 @@
import { z } from 'zod';
export const taskSchema = z.object({
taskId: z.string(),
userId: z.string(),
provider: z.string(),
model: z.string(),
mode: z.string().nullable(),
completed: z.coerce.boolean(),
tokens: z.coerce.number(),
cost: z.coerce.number(),
timestamp: z.coerce.number(),
});
export type Task = z.infer<typeof taskSchema>;