mirror of https://github.com/buster-so/buster.git
Merge pull request #778 from buster-so/buster-dev-api
Public Facing Chat Endpoint
This commit is contained in:
commit
14638c0f96
|
@ -7,6 +7,7 @@ import electricShapeRoutes from './electric-shape';
|
|||
import githubRoutes from './github';
|
||||
import metricFilesRoutes from './metric_files';
|
||||
import organizationRoutes from './organization';
|
||||
import publicRoutes from './public';
|
||||
import reportsRoutes from './reports';
|
||||
import s3IntegrationsRoutes from './s3-integrations';
|
||||
import securityRoutes from './security';
|
||||
|
@ -29,6 +30,7 @@ const app = new Hono()
|
|||
.route('/dictionaries', dictionariesRoutes)
|
||||
.route('/title', titleRoutes)
|
||||
.route('/reports', reportsRoutes)
|
||||
.route('/s3-integrations', s3IntegrationsRoutes);
|
||||
.route('/s3-integrations', s3IntegrationsRoutes)
|
||||
.route('/public', publicRoutes);
|
||||
|
||||
export default app;
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Constants for public chat API
|
||||
*/
|
||||
|
||||
// Default messages
|
||||
export const DEFAULT_MESSAGES = {
|
||||
PROCESSING_START: "I've started working on your request. I'll notify you when it's finished.",
|
||||
PROCESSING_COMPLETE: "I've finished working on your request!",
|
||||
PROCESSING_COMPLETE_GENERIC: "I've completed your request!",
|
||||
ERROR_GENERIC: 'An unexpected error occurred while processing your request',
|
||||
ERROR_INTERNAL: 'Internal server error',
|
||||
} as const;
|
||||
|
||||
// Polling configuration defaults
|
||||
export const POLLING_CONFIG = {
|
||||
INITIAL_DELAY_MS: 2000, // Start checking after 2 seconds
|
||||
INTERVAL_MS: 5000, // Check every 5 seconds
|
||||
MAX_DURATION_MS: 30 * 60 * 1000, // 30 minutes max
|
||||
BACKOFF_MULTIPLIER: 1.2, // Gradually increase interval
|
||||
MAX_INTERVAL_MS: 15000, // Max 15 seconds between checks
|
||||
} as const;
|
||||
|
||||
// SSE configuration
|
||||
export const SSE_CONFIG = {
|
||||
HEARTBEAT_INTERVAL_MS: 30000, // Send heartbeat every 30 seconds
|
||||
} as const;
|
||||
|
||||
// URL configuration
|
||||
export const URL_CONFIG = {
|
||||
DEFAULT_BASE_URL: 'https://platform.buster.so',
|
||||
} as const;
|
|
@ -0,0 +1,90 @@
|
|||
import type { ApiKeyContext, PublicChatRequest } from '@buster/server-shared';
|
||||
import { PublicChatError, PublicChatErrorCode } from '@buster/server-shared';
|
||||
import { extractMessageId, initializeChat } from './helpers/chat-functions';
|
||||
import { createSSEResponseStream } from './helpers/stream-functions';
|
||||
import { resolveAndValidateUser } from './helpers/user-functions';
|
||||
|
||||
/**
|
||||
* Main handler for public chat API requests
|
||||
* Composes all the functional components to process a chat request
|
||||
* @param request The validated public chat request
|
||||
* @param apiKey The validated API key context
|
||||
* @returns A ReadableStream for SSE responses
|
||||
*/
|
||||
export async function publicChatHandler(
|
||||
request: PublicChatRequest,
|
||||
apiKey: ApiKeyContext
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
try {
|
||||
// Step 1: Resolve and validate the user
|
||||
const user = await resolveAndValidateUser(request.email, apiKey.organizationId);
|
||||
|
||||
// Step 2: Initialize the chat with the prompt
|
||||
const chat = await initializeChat(request.prompt, user, apiKey.organizationId);
|
||||
|
||||
// Step 3: Extract the message ID from the chat
|
||||
const messageId = extractMessageId(chat);
|
||||
|
||||
// Step 4: Create and return the SSE response stream
|
||||
// The stream will handle:
|
||||
// - Sending initial status message
|
||||
// - Polling for completion
|
||||
// - Sending final response
|
||||
// - Error handling
|
||||
return createSSEResponseStream(chat.id, messageId);
|
||||
} catch (error) {
|
||||
// Handle errors that occur before streaming starts
|
||||
if (error instanceof PublicChatError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Unexpected error in public chat handler:', error);
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.INTERNAL_ERROR,
|
||||
'An unexpected error occurred',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional composition helper for better error handling
|
||||
* Can be used to chain operations with proper error boundaries
|
||||
*/
|
||||
export function compose<T>(...fns: Array<(arg: unknown) => unknown>) {
|
||||
return (initialValue: T) =>
|
||||
fns.reduce((acc, fn) => {
|
||||
try {
|
||||
return fn(acc);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicChatError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PublicChatError(PublicChatErrorCode.INTERNAL_ERROR, 'Operation failed', 500);
|
||||
}
|
||||
}, initialValue as unknown) as unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async composition helper for chaining async operations
|
||||
*/
|
||||
export function composeAsync<T>(...fns: Array<(arg: unknown) => Promise<unknown>>) {
|
||||
return async (initialValue: T) => {
|
||||
let result: unknown = initialValue;
|
||||
for (const fn of fns) {
|
||||
try {
|
||||
result = await fn(result);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicChatError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.INTERNAL_ERROR,
|
||||
'Async operation failed',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
import type { User } from '@buster/database';
|
||||
import { PublicChatError, PublicChatErrorCode } from '@buster/server-shared';
|
||||
import type { ChatCreateHandlerRequest, ChatWithMessages } from '@buster/server-shared/chats';
|
||||
import { createChatHandler } from '../../../chats/handler';
|
||||
import { URL_CONFIG } from '../constants';
|
||||
|
||||
/**
|
||||
* Initializes a chat with the given prompt
|
||||
* @param prompt The user's prompt
|
||||
* @param user The user creating the chat
|
||||
* @param organizationId The organization ID
|
||||
* @returns The created chat with messages
|
||||
*/
|
||||
export async function initializeChat(
|
||||
prompt: string,
|
||||
user: User,
|
||||
_organizationId: string
|
||||
): Promise<ChatWithMessages> {
|
||||
try {
|
||||
// Create the chat request
|
||||
const chatRequest: ChatCreateHandlerRequest = {
|
||||
prompt,
|
||||
};
|
||||
|
||||
// Use the existing chat handler
|
||||
const chat = await createChatHandler(chatRequest, user);
|
||||
|
||||
return chat;
|
||||
} catch (error) {
|
||||
console.error('Error initializing chat:', error);
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.CHAT_CREATION_FAILED,
|
||||
'Failed to create chat',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the chat link for the Buster platform
|
||||
* @param chatId The chat ID
|
||||
* @param fileId Optional file ID if a file was created
|
||||
* @param fileType Optional file type (metric_file, dashboard_file, etc.)
|
||||
* @param versionNumber Optional version number
|
||||
* @returns The formatted chat link
|
||||
*/
|
||||
export function buildChatLink(
|
||||
chatId: string,
|
||||
fileId?: string | null,
|
||||
fileType?: string | null,
|
||||
versionNumber?: number | null
|
||||
): string {
|
||||
const baseUrl = process.env.BUSTER_URL || URL_CONFIG.DEFAULT_BASE_URL;
|
||||
|
||||
// If we have file information, build the file-specific URL
|
||||
if (fileId && fileType && versionNumber != null) {
|
||||
// Convert file type to plural form for URL
|
||||
const fileTypePlural = fileType.endsWith('_file')
|
||||
? fileType.replace('_file', '_files')
|
||||
: `${fileType}s`;
|
||||
|
||||
return `${baseUrl}/app/chats/${chatId}/${fileTypePlural}/${fileId}?${fileType}_version_number=${versionNumber}`;
|
||||
}
|
||||
|
||||
// Otherwise, just return the chat URL
|
||||
return `${baseUrl}/app/chats/${chatId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the message ID from a chat
|
||||
* @param chat The chat with messages
|
||||
* @returns The latest message ID
|
||||
*/
|
||||
export function extractMessageId(chat: ChatWithMessages): string {
|
||||
if (!chat.message_ids || chat.message_ids.length === 0) {
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.CHAT_CREATION_FAILED,
|
||||
'No messages found in chat',
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
// Get the last message ID (which should be the user's prompt message)
|
||||
const messageId = chat.message_ids[chat.message_ids.length - 1];
|
||||
if (!messageId) {
|
||||
throw new PublicChatError(PublicChatErrorCode.CHAT_CREATION_FAILED, 'Invalid message ID', 500);
|
||||
}
|
||||
|
||||
return messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the trigger run ID from a chat's messages
|
||||
* This would typically be set by the chat handler after triggering the analyst task
|
||||
* @param chat The chat with messages
|
||||
* @returns The trigger run ID if available
|
||||
*/
|
||||
export function extractTriggerRunId(_chat: ChatWithMessages): string | undefined {
|
||||
// The trigger run ID would be stored in the message metadata
|
||||
// For now, we'll return undefined and handle this in the polling logic
|
||||
return undefined;
|
||||
}
|
|
@ -0,0 +1,245 @@
|
|||
import { chats, db, eq, messages } from '@buster/database';
|
||||
import { PublicChatError, PublicChatErrorCode } from '@buster/server-shared';
|
||||
import type { ChatMessage } from '@buster/server-shared/chats';
|
||||
import { DEFAULT_MESSAGES, POLLING_CONFIG } from '../constants';
|
||||
|
||||
export interface PollingConfig {
|
||||
initialDelayMs: number;
|
||||
intervalMs: number;
|
||||
maxDurationMs: number;
|
||||
backoffMultiplier?: number;
|
||||
maxIntervalMs?: number;
|
||||
}
|
||||
|
||||
export interface MessageCompletionResult {
|
||||
isCompleted: boolean;
|
||||
responseMessage?: string;
|
||||
fileInfo?: {
|
||||
fileId: string | null;
|
||||
fileType: string | null;
|
||||
versionNumber: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default polling configuration
|
||||
*/
|
||||
export const DEFAULT_POLLING_CONFIG: PollingConfig = {
|
||||
initialDelayMs: POLLING_CONFIG.INITIAL_DELAY_MS,
|
||||
intervalMs: POLLING_CONFIG.INTERVAL_MS,
|
||||
maxDurationMs: POLLING_CONFIG.MAX_DURATION_MS,
|
||||
backoffMultiplier: POLLING_CONFIG.BACKOFF_MULTIPLIER,
|
||||
maxIntervalMs: POLLING_CONFIG.MAX_INTERVAL_MS,
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a message is completed
|
||||
* @param messageId The message ID to check
|
||||
* @returns Completion status and response data
|
||||
*/
|
||||
export async function checkMessageCompletion(messageId: string): Promise<MessageCompletionResult> {
|
||||
try {
|
||||
// Query the message to check completion status
|
||||
const messageData = await db
|
||||
.select({
|
||||
isCompleted: messages.isCompleted,
|
||||
responseMessages: messages.responseMessages,
|
||||
chatId: messages.chatId,
|
||||
})
|
||||
.from(messages)
|
||||
.where(eq(messages.id, messageId))
|
||||
.limit(1);
|
||||
|
||||
const message = messageData[0];
|
||||
if (!message) {
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.MESSAGE_PROCESSING_FAILED,
|
||||
'Message not found',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
// If not completed, return early
|
||||
if (!message.isCompleted) {
|
||||
return { isCompleted: false };
|
||||
}
|
||||
|
||||
// Extract the final response message
|
||||
let responseMessage: string = DEFAULT_MESSAGES.PROCESSING_COMPLETE;
|
||||
if (message.responseMessages) {
|
||||
// responseMessages is a record/object, not an array
|
||||
const responseMessagesRecord = message.responseMessages as ChatMessage['response_messages'];
|
||||
const responseMessagesArray = Object.values(responseMessagesRecord);
|
||||
|
||||
const finalTextMessage = responseMessagesArray.find(
|
||||
(msg) => msg.type === 'text' && msg.is_final_message === true
|
||||
);
|
||||
|
||||
if (finalTextMessage && finalTextMessage.type === 'text') {
|
||||
responseMessage = finalTextMessage.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Get file information from the chat
|
||||
const chatData = await db
|
||||
.select({
|
||||
mostRecentFileId: chats.mostRecentFileId,
|
||||
mostRecentFileType: chats.mostRecentFileType,
|
||||
mostRecentVersionNumber: chats.mostRecentVersionNumber,
|
||||
})
|
||||
.from(chats)
|
||||
.where(eq(chats.id, message.chatId))
|
||||
.limit(1);
|
||||
|
||||
const chat = chatData[0];
|
||||
|
||||
const fileInfo = chat
|
||||
? {
|
||||
fileId: chat.mostRecentFileId,
|
||||
fileType: chat.mostRecentFileType,
|
||||
versionNumber: chat.mostRecentVersionNumber,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
isCompleted: true,
|
||||
responseMessage,
|
||||
...(fileInfo && { fileInfo }),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof PublicChatError) {
|
||||
throw error;
|
||||
}
|
||||
console.error('Error checking message completion:', error);
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.MESSAGE_PROCESSING_FAILED,
|
||||
'Failed to check message status',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls for message completion with exponential backoff
|
||||
* @param messageId The message ID to poll
|
||||
* @param config Polling configuration
|
||||
* @param onProgress Optional callback for progress updates
|
||||
* @returns The completion result
|
||||
*/
|
||||
export async function pollMessageCompletion(
|
||||
messageId: string,
|
||||
config: PollingConfig = DEFAULT_POLLING_CONFIG,
|
||||
onProgress?: (attempt: number, nextDelayMs: number) => void
|
||||
): Promise<MessageCompletionResult> {
|
||||
const startTime = Date.now();
|
||||
let currentInterval = config.intervalMs;
|
||||
let attempt = 0;
|
||||
|
||||
// Initial delay before first check
|
||||
await sleep(config.initialDelayMs);
|
||||
|
||||
while (Date.now() - startTime < config.maxDurationMs) {
|
||||
attempt++;
|
||||
|
||||
// Check completion status
|
||||
const result = await checkMessageCompletion(messageId);
|
||||
|
||||
if (result.isCompleted) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Calculate next interval with backoff
|
||||
if (config.backoffMultiplier && config.maxIntervalMs) {
|
||||
currentInterval = Math.min(currentInterval * config.backoffMultiplier, config.maxIntervalMs);
|
||||
}
|
||||
|
||||
// Notify progress if callback provided
|
||||
if (onProgress) {
|
||||
onProgress(attempt, currentInterval);
|
||||
}
|
||||
|
||||
// Check if we've already exceeded max duration
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
if (elapsedTime >= config.maxDurationMs) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate remaining time and wait for the minimum of interval or remaining time
|
||||
const remainingTime = config.maxDurationMs - elapsedTime;
|
||||
const waitTime = Math.min(currentInterval, remainingTime);
|
||||
|
||||
// Wait before next check
|
||||
await sleep(waitTime);
|
||||
}
|
||||
|
||||
// Timeout reached
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.MESSAGE_PROCESSING_FAILED,
|
||||
'Message processing timed out',
|
||||
504
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility function
|
||||
* @param ms Milliseconds to sleep
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an async generator for polling
|
||||
* Useful for SSE streaming
|
||||
* @param messageId The message ID to poll
|
||||
* @param config Polling configuration
|
||||
*/
|
||||
export async function* createPollingGenerator(
|
||||
messageId: string,
|
||||
config: PollingConfig = DEFAULT_POLLING_CONFIG
|
||||
): AsyncGenerator<{ status: 'pending' | 'completed'; result?: MessageCompletionResult }> {
|
||||
const startTime = Date.now();
|
||||
let currentInterval = config.intervalMs;
|
||||
|
||||
// Initial delay
|
||||
await sleep(config.initialDelayMs);
|
||||
|
||||
while (true) {
|
||||
// Check if we've exceeded max duration before attempting
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
if (elapsedTime >= config.maxDurationMs) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check completion status
|
||||
const result = await checkMessageCompletion(messageId);
|
||||
|
||||
if (result.isCompleted) {
|
||||
yield { status: 'completed', result };
|
||||
return;
|
||||
}
|
||||
|
||||
// Yield pending status
|
||||
yield { status: 'pending' };
|
||||
|
||||
// Calculate next interval with backoff
|
||||
if (config.backoffMultiplier && config.maxIntervalMs) {
|
||||
currentInterval = Math.min(currentInterval * config.backoffMultiplier, config.maxIntervalMs);
|
||||
}
|
||||
|
||||
// Check if we'll exceed max duration
|
||||
if (Date.now() - startTime + currentInterval >= config.maxDurationMs) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait before next check
|
||||
await sleep(currentInterval);
|
||||
}
|
||||
|
||||
// Timeout
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.MESSAGE_PROCESSING_FAILED,
|
||||
'Message processing timed out',
|
||||
504
|
||||
);
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
import type {
|
||||
PublicChatErrorEvent,
|
||||
PublicChatEvent,
|
||||
PublicChatResponseEvent,
|
||||
PublicChatStatusEvent,
|
||||
} from '@buster/server-shared';
|
||||
import { PublicChatError } from '@buster/server-shared';
|
||||
import { SSEStreamController } from '../../../../../utils/sse';
|
||||
import { DEFAULT_MESSAGES } from '../constants';
|
||||
import { buildChatLink } from './chat-functions';
|
||||
import { type MessageCompletionResult, createPollingGenerator } from './polling-functions';
|
||||
|
||||
/**
|
||||
* Creates an initial status event
|
||||
* @param chatId The chat ID
|
||||
* @returns The status event
|
||||
*/
|
||||
export function createInitialStatusEvent(chatId: string): PublicChatStatusEvent {
|
||||
return {
|
||||
type: 'status',
|
||||
message: DEFAULT_MESSAGES.PROCESSING_START,
|
||||
link: buildChatLink(chatId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a response event
|
||||
* @param message The response message
|
||||
* @param chatId The chat ID
|
||||
* @param fileInfo Optional file information
|
||||
* @returns The response event
|
||||
*/
|
||||
export function createResponseEvent(
|
||||
message: string,
|
||||
chatId: string,
|
||||
fileInfo?: {
|
||||
fileId: string | null;
|
||||
fileType: string | null;
|
||||
versionNumber: number | null;
|
||||
}
|
||||
): PublicChatResponseEvent {
|
||||
return {
|
||||
type: 'response',
|
||||
message,
|
||||
link: buildChatLink(chatId, fileInfo?.fileId, fileInfo?.fileType, fileInfo?.versionNumber),
|
||||
is_finished: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error event
|
||||
* @param error The error message
|
||||
* @returns The error event
|
||||
*/
|
||||
export function createErrorEvent(error: string): PublicChatErrorEvent {
|
||||
return {
|
||||
type: 'error',
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user-friendly error message from an error
|
||||
* @param error The error to process
|
||||
* @returns A user-friendly error message
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof PublicChatError) {
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return DEFAULT_MESSAGES.ERROR_GENERIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an SSE response stream for a chat
|
||||
* @param chatId The chat ID
|
||||
* @param messageId The message ID to poll
|
||||
* @returns A ReadableStream for SSE
|
||||
*/
|
||||
export function createSSEResponseStream(
|
||||
chatId: string,
|
||||
messageId: string
|
||||
): ReadableStream<Uint8Array> {
|
||||
const controller = new SSEStreamController();
|
||||
const stream = controller.createStream();
|
||||
|
||||
// Start the async processing
|
||||
processMessageWithSSE(chatId, messageId, controller).catch((error) => {
|
||||
console.error('SSE processing error:', error);
|
||||
// Send error event before closing
|
||||
const errorMessage = getErrorMessage(error);
|
||||
controller.sendEvent(createErrorEvent(errorMessage));
|
||||
controller.close();
|
||||
});
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a message and sends SSE events
|
||||
* @param chatId The chat ID
|
||||
* @param messageId The message ID
|
||||
* @param controller The SSE stream controller
|
||||
*/
|
||||
async function processMessageWithSSE(
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
controller: SSEStreamController
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Send initial status event
|
||||
const statusEvent = createInitialStatusEvent(chatId);
|
||||
controller.sendEvent(statusEvent);
|
||||
|
||||
// Start polling for completion
|
||||
const pollingGenerator = createPollingGenerator(messageId);
|
||||
|
||||
for await (const pollResult of pollingGenerator) {
|
||||
if (pollResult.status === 'completed' && pollResult.result) {
|
||||
// Send the final response
|
||||
const responseEvent = createResponseEvent(
|
||||
pollResult.result.responseMessage || DEFAULT_MESSAGES.PROCESSING_COMPLETE_GENERIC,
|
||||
chatId,
|
||||
pollResult.result.fileInfo
|
||||
);
|
||||
controller.sendEvent(responseEvent);
|
||||
break;
|
||||
}
|
||||
// For pending status, we don't send additional events
|
||||
// The initial status message is sufficient
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle errors using shared error handler
|
||||
const errorMessage = getErrorMessage(error);
|
||||
controller.sendEvent(createErrorEvent(errorMessage));
|
||||
throw error;
|
||||
} finally {
|
||||
// Always close the stream
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an async generator for SSE events
|
||||
* Useful for alternative SSE implementations
|
||||
* @param chatId The chat ID
|
||||
* @param messageId The message ID
|
||||
*/
|
||||
export async function* createSSEEventGenerator(
|
||||
chatId: string,
|
||||
messageId: string
|
||||
): AsyncGenerator<PublicChatEvent> {
|
||||
try {
|
||||
// Yield initial status
|
||||
yield createInitialStatusEvent(chatId);
|
||||
|
||||
// Poll for completion
|
||||
const pollingGenerator = createPollingGenerator(messageId);
|
||||
|
||||
for await (const pollResult of pollingGenerator) {
|
||||
if (pollResult.status === 'completed' && pollResult.result) {
|
||||
// Yield final response
|
||||
yield createResponseEvent(
|
||||
pollResult.result.responseMessage || DEFAULT_MESSAGES.PROCESSING_COMPLETE_GENERIC,
|
||||
chatId,
|
||||
pollResult.result.fileInfo
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Yield error event using shared error handler
|
||||
const errorMessage = getErrorMessage(error);
|
||||
yield createErrorEvent(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
import type { User } from '@buster/database';
|
||||
import {
|
||||
addUserToOrganization,
|
||||
createUser,
|
||||
db,
|
||||
findUserByEmailInOrganization,
|
||||
} from '@buster/database';
|
||||
import { PublicChatError, PublicChatErrorCode } from '@buster/server-shared';
|
||||
|
||||
/**
|
||||
* Resolves a user by email within an organization
|
||||
* Creates the user if they don't exist
|
||||
* @param email The user's email address
|
||||
* @param organizationId The organization ID
|
||||
* @returns The resolved user
|
||||
*/
|
||||
export async function resolveUser(email: string, organizationId: string): Promise<User> {
|
||||
try {
|
||||
// First, try to find the user in the organization
|
||||
let user = await findUserByEmailInOrganization(email, organizationId);
|
||||
|
||||
if (!user) {
|
||||
// User doesn't exist in org, create them with transaction for atomicity
|
||||
user = await db.transaction(async (_tx) => {
|
||||
// Create the user
|
||||
const newUser = await createUser(email);
|
||||
|
||||
// Add user to the organization with restricted_querier role
|
||||
await addUserToOrganization(newUser.id, organizationId, 'restricted_querier');
|
||||
|
||||
return newUser;
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Error resolving user:', error);
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.USER_CREATION_FAILED,
|
||||
'Failed to resolve user',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a user has permissions within an organization
|
||||
* @param user The user to validate
|
||||
* @param organizationId The organization ID
|
||||
* @returns True if the user has valid permissions
|
||||
*/
|
||||
export async function validateUserPermissions(
|
||||
user: User,
|
||||
_organizationId: string
|
||||
): Promise<boolean> {
|
||||
// For now, we just check that the user exists
|
||||
// In the future, we might check specific permissions
|
||||
if (!user || !user.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional permission checks can be added here
|
||||
// For example, checking if the user's role allows API access
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches user context with additional data if needed
|
||||
* @param user The user to enrich
|
||||
* @returns The enriched user
|
||||
*/
|
||||
export async function enrichUserContext(user: User): Promise<User> {
|
||||
// For now, just return the user as-is
|
||||
// In the future, we might add:
|
||||
// - User preferences
|
||||
// - API usage limits
|
||||
// - Custom attributes
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composed function to resolve and validate a user
|
||||
* @param email The user's email
|
||||
* @param organizationId The organization ID
|
||||
* @returns The validated user
|
||||
*/
|
||||
export async function resolveAndValidateUser(email: string, organizationId: string): Promise<User> {
|
||||
// Resolve the user (create if needed)
|
||||
const user = await resolveUser(email, organizationId);
|
||||
|
||||
// Validate permissions
|
||||
const hasPermissions = await validateUserPermissions(user, organizationId);
|
||||
if (!hasPermissions) {
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
'User does not have valid permissions',
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich the user context
|
||||
const enrichedUser = await enrichUserContext(user);
|
||||
|
||||
return enrichedUser;
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import { PublicChatError, PublicChatRequestSchema } from '@buster/server-shared';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { createApiKeyAuthMiddleware } from '../../../../middleware/api-key-auth';
|
||||
import { corsMiddleware } from '../../../../middleware/cors';
|
||||
import { SSEStreamController, createSSEHeaders } from '../../../../utils/sse';
|
||||
import { publicChatHandler } from './handler';
|
||||
import { createErrorEvent } from './helpers/stream-functions';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Apply CORS middleware to all routes
|
||||
app.use('*', corsMiddleware);
|
||||
|
||||
/**
|
||||
* POST /api/v2/public/chats
|
||||
*
|
||||
* Creates a new chat session with SSE streaming response
|
||||
* Requires API key authentication via Bearer token
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* "email": "user@example.com",
|
||||
* "prompt": "Your question here"
|
||||
* }
|
||||
*
|
||||
* Response: Server-Sent Events stream
|
||||
*/
|
||||
app.post(
|
||||
'/',
|
||||
createApiKeyAuthMiddleware(),
|
||||
zValidator('json', PublicChatRequestSchema),
|
||||
async (c) => {
|
||||
try {
|
||||
// Get the validated API key context
|
||||
const apiKey = c.get('apiKey');
|
||||
if (!apiKey) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Authentication required',
|
||||
code: 'INVALID_API_KEY',
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
// Get the validated request body
|
||||
const request = c.req.valid('json');
|
||||
|
||||
// Process the chat request and get the SSE stream
|
||||
const stream = await publicChatHandler(request, apiKey);
|
||||
|
||||
// Return the SSE response with proper headers
|
||||
return new Response(stream, {
|
||||
headers: createSSEHeaders(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle errors that occur during handler execution
|
||||
console.error('Public chat endpoint error:', error);
|
||||
|
||||
// Return SSE stream with error event to maintain consistent interface
|
||||
const controller = new SSEStreamController();
|
||||
const stream = controller.createStream();
|
||||
|
||||
// Send error event
|
||||
const errorMessage =
|
||||
error instanceof PublicChatError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Internal server error';
|
||||
|
||||
controller.sendEvent(createErrorEvent(errorMessage));
|
||||
controller.close();
|
||||
|
||||
return new Response(stream, {
|
||||
headers: createSSEHeaders(),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default app;
|
|
@ -0,0 +1,24 @@
|
|||
import { Hono } from 'hono';
|
||||
import chats from './chats';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Mount the chats route
|
||||
app.route('/chats', chats);
|
||||
|
||||
// Add a root handler for the public API
|
||||
app.get('/', (c) => {
|
||||
return c.json({
|
||||
message: 'Buster Public API v2',
|
||||
endpoints: {
|
||||
chats: {
|
||||
method: 'POST',
|
||||
path: '/api/v2/public/chats',
|
||||
description: 'Create a chat session with SSE streaming',
|
||||
authentication: 'Bearer token (API key)',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
|
@ -0,0 +1,118 @@
|
|||
import { getApiKeyOrganization, validateApiKey } from '@buster/database';
|
||||
import { type ApiKeyContext, PublicChatError, PublicChatErrorCode } from '@buster/server-shared';
|
||||
import type { Context, Next } from 'hono';
|
||||
|
||||
/**
|
||||
* Extracts Bearer token from Authorization header
|
||||
* @param headers The request headers
|
||||
* @returns The token if found, null otherwise
|
||||
*/
|
||||
export function extractBearerToken(headers: Headers): string | null {
|
||||
const authHeader = headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an API key token and returns the validation result
|
||||
* @param token The API key token to validate
|
||||
* @returns The API key context if valid
|
||||
* @throws PublicChatError if validation fails
|
||||
*/
|
||||
export async function validateApiKeyToken(token: string): Promise<ApiKeyContext> {
|
||||
// Validate the API key against the database
|
||||
const apiKey = await validateApiKey(token);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.INVALID_API_KEY,
|
||||
'Invalid or expired API key',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the organization has payment required flag
|
||||
const organization = await getApiKeyOrganization(apiKey.id);
|
||||
|
||||
if (!organization) {
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.ORGANIZATION_ERROR,
|
||||
'Organization not found',
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
// Check payment status in production
|
||||
if (process.env.ENVIRONMENT === 'production' && organization.paymentRequired) {
|
||||
throw new PublicChatError(
|
||||
PublicChatErrorCode.PAYMENT_REQUIRED,
|
||||
'Payment required for this organization',
|
||||
402
|
||||
);
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates API key authentication middleware for Hono
|
||||
* @returns Hono middleware handler
|
||||
*/
|
||||
export function createApiKeyAuthMiddleware() {
|
||||
return async (c: Context, next: Next) => {
|
||||
try {
|
||||
// Extract Bearer token from Authorization header
|
||||
const token = extractBearerToken(c.req.raw.headers);
|
||||
|
||||
if (!token) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Missing API key',
|
||||
code: PublicChatErrorCode.INVALID_API_KEY,
|
||||
},
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the API key
|
||||
const apiKeyContext = await validateApiKeyToken(token);
|
||||
|
||||
// Set the API key context for downstream handlers
|
||||
c.set('apiKey', apiKeyContext);
|
||||
|
||||
// Continue to the next handler
|
||||
return await next();
|
||||
} catch (error) {
|
||||
// Handle PublicChatError specifically
|
||||
if (error instanceof PublicChatError) {
|
||||
// @ts-expect-error - statusCode is a number but Hono expects ContentfulStatusCode type
|
||||
return c.json({ error: error.message, code: error.code }, error.statusCode);
|
||||
}
|
||||
|
||||
// Handle unexpected errors
|
||||
console.error('API key authentication error:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Authentication failed',
|
||||
code: PublicChatErrorCode.INTERNAL_ERROR,
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if context has API key
|
||||
*/
|
||||
export function hasApiKey(c: Context): c is Context & { get(key: 'apiKey'): ApiKeyContext } {
|
||||
return c.get('apiKey') !== undefined;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import type { User as BusterUser } from '@buster/database';
|
||||
import type { ApiKeyContext } from '@buster/server-shared';
|
||||
import type { InstallationCallbackRequest } from '@buster/server-shared/github';
|
||||
import type { OrganizationRole } from '@buster/server-shared/organization';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
@ -26,5 +27,9 @@ declare module 'hono' {
|
|||
* GitHub webhook payload. Set by the githubWebhookValidator middleware.
|
||||
*/
|
||||
readonly githubPayload?: InstallationCallbackRequest;
|
||||
/**
|
||||
* API key context for public API endpoints. Set by the createApiKeyAuthMiddleware.
|
||||
*/
|
||||
readonly apiKey?: ApiKeyContext;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,195 @@
|
|||
import type { PublicChatEvent } from '@buster/server-shared';
|
||||
|
||||
/**
|
||||
* Creates headers for Server-Sent Events response
|
||||
* @returns Headers configured for SSE
|
||||
*/
|
||||
export function createSSEHeaders(): Headers {
|
||||
return new Headers({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // Disable Nginx buffering
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats data as an SSE event
|
||||
* @param data The data to send
|
||||
* @param eventType Optional event type
|
||||
* @param id Optional event ID
|
||||
* @returns Formatted SSE event string
|
||||
*/
|
||||
export function formatSSEEvent(data: unknown, eventType?: string, id?: string): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (id) {
|
||||
lines.push(`id: ${id}`);
|
||||
}
|
||||
|
||||
if (eventType) {
|
||||
lines.push(`event: ${eventType}`);
|
||||
}
|
||||
|
||||
// Serialize data as JSON and split by newlines for SSE format
|
||||
const jsonData = JSON.stringify(data);
|
||||
const dataLines = jsonData.split('\n');
|
||||
for (const line of dataLines) {
|
||||
lines.push(`data: ${line}`);
|
||||
}
|
||||
|
||||
// SSE events end with double newline
|
||||
return `${lines.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a heartbeat event to keep the connection alive
|
||||
* @returns Formatted heartbeat SSE event
|
||||
*/
|
||||
export function createHeartbeatEvent(): string {
|
||||
return ': heartbeat\n\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE stream controller for managing the stream lifecycle
|
||||
*/
|
||||
export class SSEStreamController {
|
||||
private encoder = new TextEncoder();
|
||||
private controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private closed = false;
|
||||
|
||||
/**
|
||||
* Creates a new ReadableStream for SSE
|
||||
* @param heartbeatMs Optional heartbeat interval in milliseconds (default: 30000)
|
||||
* @returns ReadableStream for SSE responses
|
||||
*/
|
||||
createStream(heartbeatMs = 30000): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start: (controller) => {
|
||||
this.controller = controller;
|
||||
|
||||
// Set up heartbeat to keep connection alive
|
||||
if (heartbeatMs > 0) {
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (!this.closed) {
|
||||
this.sendRaw(createHeartbeatEvent());
|
||||
}
|
||||
}, heartbeatMs);
|
||||
}
|
||||
},
|
||||
|
||||
cancel: () => {
|
||||
this.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends raw string data to the stream
|
||||
* @param data Raw string data to send
|
||||
*/
|
||||
private sendRaw(data: string): void {
|
||||
if (this.controller && !this.closed) {
|
||||
try {
|
||||
this.controller.enqueue(this.encoder.encode(data));
|
||||
} catch (error) {
|
||||
console.error('Error sending SSE data:', error);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event to the stream
|
||||
* @param event The event to send
|
||||
* @param eventType Optional event type
|
||||
* @param id Optional event ID
|
||||
*/
|
||||
sendEvent<T = PublicChatEvent>(event: T, eventType?: string, id?: string): void {
|
||||
const formatted = formatSSEEvent(event, eventType, id);
|
||||
this.sendRaw(formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a comment (for heartbeat or debugging)
|
||||
* @param comment The comment text
|
||||
*/
|
||||
sendComment(comment: string): void {
|
||||
this.sendRaw(`: ${comment}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the stream and cleans up resources
|
||||
*/
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
|
||||
this.closed = true;
|
||||
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
|
||||
if (this.controller) {
|
||||
try {
|
||||
this.controller.close();
|
||||
} catch (error) {
|
||||
// Controller might already be closed
|
||||
console.warn('Error closing SSE controller:', error);
|
||||
}
|
||||
this.controller = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the stream is closed
|
||||
*/
|
||||
isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a simple SSE response stream
|
||||
* @param eventGenerator Async generator that yields events
|
||||
* @param heartbeatMs Optional heartbeat interval
|
||||
* @returns ReadableStream for SSE
|
||||
*/
|
||||
export async function* createSSEEventGenerator<T>(
|
||||
eventGenerator: AsyncGenerator<T>,
|
||||
heartbeatMs = 30000
|
||||
): AsyncGenerator<string> {
|
||||
// Start with a comment to establish the connection
|
||||
yield ': connection established\n\n';
|
||||
|
||||
let lastEventTime = Date.now();
|
||||
|
||||
// Set up heartbeat generator
|
||||
const checkHeartbeat = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastEventTime > heartbeatMs) {
|
||||
lastEventTime = now;
|
||||
return createHeartbeatEvent();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const event of eventGenerator) {
|
||||
// Check if we need to send a heartbeat
|
||||
const heartbeat = checkHeartbeat();
|
||||
if (heartbeat) {
|
||||
yield heartbeat;
|
||||
}
|
||||
|
||||
// Send the actual event
|
||||
yield formatSSEEvent(event);
|
||||
lastEventTime = Date.now();
|
||||
}
|
||||
} finally {
|
||||
// Send a final comment to indicate stream is closing
|
||||
yield ': stream closed\n\n';
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './validate-api-key';
|
|
@ -0,0 +1,93 @@
|
|||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { db } from '../../connection';
|
||||
import { apiKeys, organizations, users } from '../../schema';
|
||||
|
||||
export interface ApiKeyValidation {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
organizationId: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an API key and returns the associated data if valid
|
||||
* @param key The API key to validate
|
||||
* @returns The API key data if valid, null otherwise
|
||||
*/
|
||||
export async function validateApiKey(key: string): Promise<ApiKeyValidation | null> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
ownerId: apiKeys.ownerId,
|
||||
organizationId: apiKeys.organizationId,
|
||||
key: apiKeys.key,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.key, key), isNull(apiKeys.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
} catch (error) {
|
||||
console.error('Error validating API key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the organization associated with an API key
|
||||
* @param apiKeyId The API key ID
|
||||
* @returns The organization data if found, null otherwise
|
||||
*/
|
||||
export async function getApiKeyOrganization(apiKeyId: string): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
paymentRequired: boolean;
|
||||
} | null> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
id: organizations.id,
|
||||
name: organizations.name,
|
||||
paymentRequired: organizations.paymentRequired,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.innerJoin(organizations, eq(apiKeys.organizationId, organizations.id))
|
||||
.where(and(eq(apiKeys.id, apiKeyId), isNull(apiKeys.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
} catch (error) {
|
||||
console.error('Error getting API key organization:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the owner user of an API key
|
||||
* @param apiKeyId The API key ID
|
||||
* @returns The user data if found, null otherwise
|
||||
*/
|
||||
export async function getApiKeyOwner(apiKeyId: string): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.innerJoin(users, eq(apiKeys.ownerId, users.id))
|
||||
.where(and(eq(apiKeys.id, apiKeyId), isNull(apiKeys.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
} catch (error) {
|
||||
console.error('Error getting API key owner:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -51,7 +51,8 @@ describe('Asset Queries', () => {
|
|||
limit: vi.fn().mockResolvedValue([mockReport]),
|
||||
};
|
||||
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain as any);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain);
|
||||
|
||||
const result = await getAssetDetailsById({
|
||||
assetId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
|
@ -91,7 +92,8 @@ describe('Asset Queries', () => {
|
|||
limit: vi.fn().mockResolvedValue([mockReport]),
|
||||
};
|
||||
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain as any);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain);
|
||||
|
||||
const result = await getAssetDetailsById({
|
||||
assetId: '223e4567-e89b-12d3-a456-426614174000',
|
||||
|
@ -109,7 +111,8 @@ describe('Asset Queries', () => {
|
|||
limit: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain as any);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain);
|
||||
|
||||
const result = await getAssetDetailsById({
|
||||
assetId: '323e4567-e89b-12d3-a456-426614174000',
|
||||
|
@ -135,7 +138,8 @@ describe('Asset Queries', () => {
|
|||
limit: vi.fn().mockResolvedValue([mockMetric]),
|
||||
};
|
||||
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain as any);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain);
|
||||
|
||||
const result = await getAssetDetailsById({
|
||||
assetId: '423e4567-e89b-12d3-a456-426614174000',
|
||||
|
@ -178,7 +182,8 @@ describe('Asset Queries', () => {
|
|||
limit: vi.fn().mockResolvedValue([mockDashboard]),
|
||||
};
|
||||
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain as any);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).select.mockReturnValue(mockDbChain);
|
||||
|
||||
const result = await getAssetDetailsById({
|
||||
assetId: '523e4567-e89b-12d3-a456-426614174000',
|
||||
|
@ -236,8 +241,10 @@ describe('Asset Queries', () => {
|
|||
]),
|
||||
};
|
||||
|
||||
vi.mocked(db).select.mockReturnValue(selectMock as any);
|
||||
vi.mocked(db).insert.mockReturnValue(insertMock as any);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).select.mockReturnValue(selectMock);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).insert.mockReturnValue(insertMock);
|
||||
|
||||
const result = await generateAssetMessages({
|
||||
assetId: '623e4567-e89b-12d3-a456-426614174000',
|
||||
|
@ -275,7 +282,8 @@ describe('Asset Queries', () => {
|
|||
limit: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
vi.mocked(db).select.mockReturnValue(selectMock as any);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).select.mockReturnValue(selectMock);
|
||||
|
||||
await expect(
|
||||
generateAssetMessages({
|
||||
|
@ -323,8 +331,10 @@ describe('Asset Queries', () => {
|
|||
]),
|
||||
};
|
||||
|
||||
vi.mocked(db).select.mockReturnValue(selectMock as any);
|
||||
vi.mocked(db).insert.mockReturnValue(insertMock as any);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).select.mockReturnValue(selectMock);
|
||||
// @ts-expect-error - Mock type doesn't match Drizzle's complex type chain
|
||||
vi.mocked(db).insert.mockReturnValue(insertMock);
|
||||
|
||||
await generateAssetMessages({
|
||||
assetId: 'c23e4567-e89b-12d3-a456-426614174000',
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './api-keys';
|
||||
export * from './messages';
|
||||
export * from './users';
|
||||
export * from './dataSources';
|
||||
|
|
|
@ -49,7 +49,7 @@ export async function listS3Integrations(
|
|||
|
||||
const integrations = await query;
|
||||
|
||||
return integrations.map((integration: any) => ({
|
||||
return integrations.map((integration) => ({
|
||||
id: integration.id,
|
||||
provider: integration.provider,
|
||||
organizationId: integration.organizationId,
|
||||
|
|
|
@ -2,3 +2,4 @@ export * from './user';
|
|||
export * from './users-to-organizations';
|
||||
export * from './find-user-by-email';
|
||||
export * from './get-user-organizations';
|
||||
export * from './user-queries';
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { db } from '../../connection';
|
||||
import { users, usersToOrganizations } from '../../schema';
|
||||
import type { User } from './user';
|
||||
|
||||
// Use the full User type from the schema internally
|
||||
type FullUser = typeof users.$inferSelect;
|
||||
|
||||
/**
|
||||
* Converts a full user to the public User type
|
||||
*/
|
||||
function toPublicUser(user: FullUser): User {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by email address (alternative implementation)
|
||||
* @param email The email address to search for
|
||||
* @returns The user if found, null otherwise
|
||||
*/
|
||||
export async function findUserByEmailAlt(email: string): Promise<User | null> {
|
||||
try {
|
||||
const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
|
||||
const user = result[0];
|
||||
return user ? toPublicUser(user) : null;
|
||||
} catch (error) {
|
||||
console.error('Error finding user by email:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by email within a specific organization
|
||||
* @param email The email address to search for
|
||||
* @param organizationId The organization ID to search within
|
||||
* @returns The user if found in the organization, null otherwise
|
||||
*/
|
||||
export async function findUserByEmailInOrganization(
|
||||
email: string,
|
||||
organizationId: string
|
||||
): Promise<User | null> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
config: users.config,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
attributes: users.attributes,
|
||||
avatarUrl: users.avatarUrl,
|
||||
})
|
||||
.from(users)
|
||||
.innerJoin(
|
||||
usersToOrganizations,
|
||||
and(
|
||||
eq(users.id, usersToOrganizations.userId),
|
||||
eq(usersToOrganizations.organizationId, organizationId),
|
||||
isNull(usersToOrganizations.deletedAt)
|
||||
)
|
||||
)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
const user = result[0];
|
||||
return user ? toPublicUser(user) : null;
|
||||
} catch (error) {
|
||||
console.error('Error finding user in organization:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user
|
||||
* @param email The user's email address
|
||||
* @param name Optional name for the user
|
||||
* @returns The created user
|
||||
*/
|
||||
export async function createUser(email: string, name?: string): Promise<User> {
|
||||
try {
|
||||
const result = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
name: name || email.split('@')[0], // Use email prefix as default name
|
||||
config: {},
|
||||
attributes: {},
|
||||
})
|
||||
.returning();
|
||||
|
||||
const user = result[0];
|
||||
if (!user) {
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
|
||||
return toPublicUser(user);
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a user to an organization
|
||||
* @param userId The user ID
|
||||
* @param organizationId The organization ID
|
||||
* @param role The user's role in the organization
|
||||
*/
|
||||
export async function addUserToOrganization(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
role:
|
||||
| 'workspace_admin'
|
||||
| 'data_admin'
|
||||
| 'querier'
|
||||
| 'restricted_querier'
|
||||
| 'viewer' = 'restricted_querier'
|
||||
) {
|
||||
try {
|
||||
await db.insert(usersToOrganizations).values({
|
||||
userId,
|
||||
organizationId,
|
||||
role,
|
||||
sharingSetting: 'none',
|
||||
editSql: false,
|
||||
uploadCsv: false,
|
||||
exportAssets: false,
|
||||
emailSlackEnabled: false,
|
||||
createdBy: userId, // Self-created for API users
|
||||
updatedBy: userId,
|
||||
status: 'active',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding user to organization:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ export * from './github';
|
|||
export * from './message';
|
||||
export * from './metrics';
|
||||
export * from './organization';
|
||||
export * from './public-chat';
|
||||
export * from './s3-integrations';
|
||||
export * from './security';
|
||||
// Export share module (has some naming conflicts with chats and metrics)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './types';
|
|
@ -0,0 +1,73 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Request schema for public chat API
|
||||
export const PublicChatRequestSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
prompt: z.string().min(1, 'Prompt cannot be empty'),
|
||||
});
|
||||
|
||||
export type PublicChatRequest = z.infer<typeof PublicChatRequestSchema>;
|
||||
|
||||
// SSE event types
|
||||
export const PublicChatStatusEventSchema = z.object({
|
||||
type: z.literal('status'),
|
||||
message: z.string(),
|
||||
link: z.string(),
|
||||
});
|
||||
|
||||
export const PublicChatResponseEventSchema = z.object({
|
||||
type: z.literal('response'),
|
||||
message: z.string(),
|
||||
link: z.string(),
|
||||
is_finished: z.literal(true),
|
||||
});
|
||||
|
||||
export const PublicChatErrorEventSchema = z.object({
|
||||
type: z.literal('error'),
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
// Union of all event types
|
||||
export const PublicChatEventSchema = z.discriminatedUnion('type', [
|
||||
PublicChatStatusEventSchema,
|
||||
PublicChatResponseEventSchema,
|
||||
PublicChatErrorEventSchema,
|
||||
]);
|
||||
|
||||
export type PublicChatStatusEvent = z.infer<typeof PublicChatStatusEventSchema>;
|
||||
export type PublicChatResponseEvent = z.infer<typeof PublicChatResponseEventSchema>;
|
||||
export type PublicChatErrorEvent = z.infer<typeof PublicChatErrorEventSchema>;
|
||||
export type PublicChatEvent = z.infer<typeof PublicChatEventSchema>;
|
||||
|
||||
// API Key validation result
|
||||
export interface ApiKeyContext {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
organizationId: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
// Error codes for public chat API
|
||||
export enum PublicChatErrorCode {
|
||||
INVALID_API_KEY = 'INVALID_API_KEY',
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
USER_CREATION_FAILED = 'USER_CREATION_FAILED',
|
||||
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
|
||||
ORGANIZATION_ERROR = 'ORGANIZATION_ERROR',
|
||||
CHAT_CREATION_FAILED = 'CHAT_CREATION_FAILED',
|
||||
MESSAGE_PROCESSING_FAILED = 'MESSAGE_PROCESSING_FAILED',
|
||||
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
||||
}
|
||||
|
||||
// Custom error class for public chat API
|
||||
export class PublicChatError extends Error {
|
||||
constructor(
|
||||
public code: PublicChatErrorCode,
|
||||
message: string,
|
||||
public statusCode = 500
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PublicChatError';
|
||||
}
|
||||
}
|
|
@ -55,11 +55,11 @@ catalogs:
|
|||
specifier: ^2.50.0
|
||||
version: 2.50.2
|
||||
'@trigger.dev/build':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.1
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
'@trigger.dev/sdk':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
ai:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5
|
||||
|
@ -207,7 +207,7 @@ importers:
|
|||
version: 2.50.2
|
||||
'@trigger.dev/sdk':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.0(ai@5.0.5(zod@3.25.76))(zod@3.25.76)
|
||||
version: 4.0.2(ai@5.0.5(zod@3.25.76))(zod@3.25.76)
|
||||
ai:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.5(zod@3.25.76)
|
||||
|
@ -286,7 +286,7 @@ importers:
|
|||
version: link:../../packages/web-tools
|
||||
'@trigger.dev/sdk':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.0(ai@5.0.5(zod@3.25.76))(zod@3.25.76)
|
||||
version: 4.0.2(ai@5.0.5(zod@3.25.76))(zod@3.25.76)
|
||||
ai:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.5(zod@3.25.76)
|
||||
|
@ -305,7 +305,7 @@ importers:
|
|||
devDependencies:
|
||||
'@trigger.dev/build':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.1(typescript@5.9.2)
|
||||
version: 4.0.2(typescript@5.9.2)
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
|
@ -5376,20 +5376,16 @@ packages:
|
|||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@trigger.dev/build@4.0.1':
|
||||
resolution: {integrity: sha512-PGOnCPjVSKkj72xmJb6mdRbzDSP3Ti/C5/tfaBFdSZ7qcoVctSzDfS5iwEGsSoSWSIv+MVy12c4v7Ji/r7MO1A==}
|
||||
'@trigger.dev/build@4.0.2':
|
||||
resolution: {integrity: sha512-GOqjGIUXWEEIfWqY2o+xr//4KTHYoRQIML8cCoP/L8x1wPb45qFJWTwNaECgYp9i+9vPMI4/G3Jm/jvWp9xznQ==}
|
||||
engines: {node: '>=18.20.0'}
|
||||
|
||||
'@trigger.dev/core@4.0.0':
|
||||
resolution: {integrity: sha512-VlRMN6RPeqU66e/j0fGmWTn97DY1b+ChsMDDBm62jZ3N9XtiOlDkrWNtggPoxPtyXsHuShllo/3gpiZDvhtKww==}
|
||||
'@trigger.dev/core@4.0.2':
|
||||
resolution: {integrity: sha512-hc/alfT7iVdJNZ5YSMbGR9FirLjURqdZ7tCBX4btKas0GDg6M5onwcQsJ3oom5TDp/Nrt+dHaviNMhFxhKCu3g==}
|
||||
engines: {node: '>=18.20.0'}
|
||||
|
||||
'@trigger.dev/core@4.0.1':
|
||||
resolution: {integrity: sha512-NTffiVPy/zFopujdptGGoy3lj3/CKV16JA8CobCfsEpDfu+K+wEys+9p8PFY8j5I0UI86aqlFpJu9/VRqUQ/yQ==}
|
||||
engines: {node: '>=18.20.0'}
|
||||
|
||||
'@trigger.dev/sdk@4.0.0':
|
||||
resolution: {integrity: sha512-rq7XvY4jxCmWr6libN1egw8w0Bq0TWbbnAxCCXDScgWEszLauYmXy8WaVlJyxbwslVMHsvXP36JBFa3J3ay2yg==}
|
||||
'@trigger.dev/sdk@4.0.2':
|
||||
resolution: {integrity: sha512-ulhWJRSHPXOHz0bMvkhAKThkW63x7lnjAb87LPi6dUps1YwwoOL8Nkr15xLXa73UrldPFT+9Y/GvQ9qpzU478w==}
|
||||
engines: {node: '>=18.20.0'}
|
||||
peerDependencies:
|
||||
ai: ^4.2.0 || ^5.0.0
|
||||
|
@ -8910,10 +8906,6 @@ packages:
|
|||
lodash.flattendeep@4.4.0:
|
||||
resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==}
|
||||
|
||||
lodash.get@4.4.2:
|
||||
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
|
||||
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
|
@ -17755,9 +17747,9 @@ snapshots:
|
|||
|
||||
'@tootallnate/once@2.0.0': {}
|
||||
|
||||
'@trigger.dev/build@4.0.1(typescript@5.9.2)':
|
||||
'@trigger.dev/build@4.0.2(typescript@5.9.2)':
|
||||
dependencies:
|
||||
'@trigger.dev/core': 4.0.1
|
||||
'@trigger.dev/core': 4.0.2
|
||||
pkg-types: 1.3.1
|
||||
tinyglobby: 0.2.14
|
||||
tsconfck: 3.1.3(typescript@5.9.2)
|
||||
|
@ -17767,47 +17759,7 @@ snapshots:
|
|||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
'@trigger.dev/core@4.0.0':
|
||||
dependencies:
|
||||
'@bugsnag/cuid': 3.2.1
|
||||
'@electric-sql/client': 1.0.0-beta.1
|
||||
'@google-cloud/precise-date': 4.0.0
|
||||
'@jsonhero/path': 1.0.21
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/api-logs': 0.203.0
|
||||
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/exporter-logs-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/exporter-trace-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/semantic-conventions': 1.36.0
|
||||
dequal: 2.0.3
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.3
|
||||
execa: 8.0.1
|
||||
humanize-duration: 3.33.0
|
||||
jose: 5.10.0
|
||||
lodash.get: 4.4.2
|
||||
nanoid: 3.3.8
|
||||
prom-client: 15.1.3
|
||||
socket.io: 4.7.4
|
||||
socket.io-client: 4.7.5
|
||||
std-env: 3.9.0
|
||||
superjson: 2.2.2
|
||||
tinyexec: 0.3.2
|
||||
uncrypto: 0.1.3
|
||||
zod: 3.25.76
|
||||
zod-error: 1.5.0
|
||||
zod-validation-error: 1.5.0(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@trigger.dev/core@4.0.1':
|
||||
'@trigger.dev/core@4.0.2':
|
||||
dependencies:
|
||||
'@bugsnag/cuid': 3.2.1
|
||||
'@electric-sql/client': 1.0.0-beta.1
|
||||
|
@ -17846,11 +17798,11 @@ snapshots:
|
|||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@trigger.dev/sdk@4.0.0(ai@5.0.5(zod@3.25.76))(zod@3.25.76)':
|
||||
'@trigger.dev/sdk@4.0.2(ai@5.0.5(zod@3.25.76))(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/semantic-conventions': 1.36.0
|
||||
'@trigger.dev/core': 4.0.0
|
||||
'@trigger.dev/core': 4.0.2
|
||||
chalk: 5.4.1
|
||||
cronstrue: 2.59.0
|
||||
debug: 4.4.1
|
||||
|
@ -20220,7 +20172,7 @@ snapshots:
|
|||
'@typescript-eslint/parser': 8.35.1(eslint@8.57.1)(typescript@5.9.2)
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||
|
@ -20244,7 +20196,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.1
|
||||
|
@ -20259,14 +20211,14 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.35.1(eslint@8.57.1)(typescript@5.9.2)
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
@ -20281,7 +20233,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.35.1(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
|
@ -21902,8 +21854,6 @@ snapshots:
|
|||
|
||||
lodash.flattendeep@4.4.0: {}
|
||||
|
||||
lodash.get@4.4.2: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
|
|
@ -11,8 +11,8 @@ packages:
|
|||
|
||||
catalog:
|
||||
"@supabase/supabase-js": "^2.50.0"
|
||||
"@trigger.dev/build": "^4.0.0"
|
||||
"@trigger.dev/sdk": "^4.0.0"
|
||||
"@trigger.dev/build": "^4.0.2"
|
||||
"@trigger.dev/sdk": "^4.0.2"
|
||||
"@platejs/autoformat": "^49.0.0"
|
||||
"@platejs/basic-nodes": "^49.0.0"
|
||||
ai: "^5.0.5"
|
||||
|
|
Loading…
Reference in New Issue