Merge pull request #778 from buster-so/buster-dev-api

Public Facing Chat Endpoint
This commit is contained in:
dal 2025-09-02 13:15:25 -06:00 committed by GitHub
commit 14638c0f96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1543 additions and 87 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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;
};
}

View File

@ -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;
}

View File

@ -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
);
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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';
}
}

View File

@ -0,0 +1 @@
export * from './validate-api-key';

View File

@ -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;
}
}

View File

@ -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',

View File

@ -1,3 +1,4 @@
export * from './api-keys';
export * from './messages';
export * from './users';
export * from './dataSources';

View File

@ -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,

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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)

View File

@ -0,0 +1 @@
export * from './types';

View File

@ -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';
}
}

View File

@ -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: {}

View File

@ -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"