import { ExtendProcedureContextFunction, Procedure, ProcedureContext } from '../models/procedure.model'; import { PROJECT_DIR } from '../constants/projectDir'; import { AppSchema, checkTsUsage, DataSource, Stage, Page, TablePageConfig, DashboardPageConfig, DashboardPageGetStatDataResult, DashboardPageGetCardDataResult, checkUserForRoles, InternalApiSchema, PartialTablePageConfig, transformStringToTablePageNestedTableKey, PartialDashboardPageConfig, DashboardPageConfigStat, DashboardPageConfigCard, TablePageInitiateRecordsExportResult, normalizeAppBasePath, IdentityProviderUserWithRoles, RpcRequestBody, MainJsonSchema, readAppSchema } from '@kottster/common'; import { DataSourceRegistry } from './dataSourceRegistry'; import { ActionService } from '../services/action.service'; import { DataSourceAdapter } from '../models/dataSourceAdapter.model'; import { Request, Response, NextFunction } from 'express'; import { createServer } from '../factories/createServer'; import { IdentityProvider, PostAuthMiddleware } from './identityProvider'; import { HttpError, UnauthorizedError } from '../errors/httpError'; import { FileReader } from '../services/fileReader.service'; import { Exporter } from '../services/exporter.service'; import dayjs from 'dayjs'; import type { Express } from 'express'; import { ExternalIdentityProvider } from './externalIdentityProvider'; import { storageService } from '../services/storage.service'; import { VERSION } from '../version'; type RequestHandler = (req: Request, res: Response, next: NextFunction) => void; export interface KottsterAppOptions { /** * The secret key used to sign JWT tokens */ secretKey?: string; /** * The Kottster API token for the appen. * If not provided, some features that require server-side requests to Kottster API will not work (e.g. sql query generation, AI features, etc.) */ kottsterApiToken?: string; /** * The identity provider configuration */ identityProvider?: IdentityProvider | ExternalIdentityProvider; /** * Custom validation middleware. * This middleware will be called after the JWT token is validated. You can use it to perform additional checks or modify the request object. * @example https://kottster.app/docs/security/authentication#custom-validation-middleware */ postAuthMiddleware?: PostAuthMiddleware; /** * Activates the Professional license features. */ professional?: Function; /** * Enable read-only mode * @hidden */ __readOnlyMode?: boolean; /** * Custom token validation function * @hidden */ __ensureValidToken?: (request: Request) => Promise; /** * @deprecated Do not pass schema here anymore. The schema is now read from the kottster-app.json file automatically. * @hidden */ schema?: MainJsonSchema | Record; /** * Allows developers to customize the Express app instance. * * Useful for: * • adding custom middleware * • modifying server settings * • enabling CORS, proxies, cookies * • registering additional routes * * @param app The Express application instance */ configureExpressApp?: (app: Express) => void; } interface EnsureValidTokenResponse { isTokenValid: boolean; user: IdentityProviderUserWithRoles | null; invalidTokenErrorMessage?: string; } /** * The main app class */ export class KottsterApp { public readonly appId: string; private readonly secretKey: string; private readonly kottsterApiToken?: string; public readonly usingTsc: boolean; public readonly readOnlyMode: boolean = false; public readonly stage: Stage = process.env.KOTTSTER_APP_STAGE === Stage.development ? Stage.development : Stage.production; public readonly basePath: string = '/'; private dataSources: DataSource[] = []; public license?: string; public licenseActivationObj?: Record; public licenseData?: Record; public identityProvider?: IdentityProvider; public externalIdentityProvider?: ExternalIdentityProvider; public exporter: Exporter; public schema: AppSchema; private customEnsureValidToken?: (request: Request) => Promise; private postAuthMiddleware?: PostAuthMiddleware; public configureExpressApp?: (app: Express) => void; public loadedPageConfigs: Page[] = []; public loadPageConfigs(): Page[] { const isDevelopment = this.stage === Stage.development; const fileReader = new FileReader(isDevelopment); this.loadedPageConfigs = fileReader.getPageConfigs(); return this.loadedPageConfigs; } public extendProcedureContext: ExtendProcedureContextFunction; public getServerPackageVersion() { return VERSION; } public getSecretKey() { return `${this.secretKey}`; } public getKottsterApiToken() { return this.kottsterApiToken; } constructor(options: KottsterAppOptions) { const appSchema = readAppSchema(PROJECT_DIR, this.stage === Stage.development); this.appId = appSchema.main.id ?? ''; this.secretKey = options.secretKey ?? ''; this.kottsterApiToken = options.kottsterApiToken; this.usingTsc = checkTsUsage(PROJECT_DIR); this.schema = appSchema; this.customEnsureValidToken = options.__ensureValidToken; this.postAuthMiddleware = options.postAuthMiddleware; this.readOnlyMode = options.__readOnlyMode ?? false; this.configureExpressApp = options.configureExpressApp; // Set license if (options.professional) { const activationObj = options.professional?.(this); this.licenseActivationObj = activationObj; this.license = activationObj['license']; this.licenseData = activationObj['dl'](this.license); } // Set base path const basePath = appSchema.main.basePath; if (basePath) { this.basePath = normalizeAppBasePath(basePath); } // Set identity providers if (!options.identityProvider) { throw new Error('Your KottsterApp must be configured with an identity provider. See https://kottster.app/docs/upgrade-to-v3-2 for more details.'); }; if (options.identityProvider?.['external']) { if (!this.licenseData?.['features']?.includes('sso')) { throw new Error('Professional license is required to use external identity providers.'); } this.externalIdentityProvider = options.identityProvider as ExternalIdentityProvider; } else { this.identityProvider = options.identityProvider as IdentityProvider; this.identityProvider!.setApp(this); } // Set up exporter this.exporter = new Exporter(); } async initialize() { await this.identityProvider?.initialize(); // Load license data if (this.licenseActivationObj && this.stage === Stage.production) { const res = await fetch('https://api.kottster.app/v3/apps/license/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ license: this.license, }), }); if (!res.ok) { throw new Error(`Invalid license`); } const data = await res.json(); if (JSON.stringify(data) !== JSON.stringify(this.licenseData)) { throw new Error('Invalid license'); } } } /** * Load from a data source registry * @param registry The data source registry */ public loadFromDataSourceRegistry(registry: DataSourceRegistry<{}>) { this.dataSources = Object.values(registry.dataSources); this.dataSources.forEach(dataSource => { const adapter = dataSource.adapter as DataSourceAdapter; if (this) { adapter.setApp(this); adapter.setData(dataSource); adapter.setTablesConfig(dataSource.tablesConfig); adapter.connect(); }; }); } /** * Register a context middleware * @param fn The function to extend the context */ public registerContextMiddleware(fn: ExtendProcedureContextFunction) { this.extendProcedureContext = fn; } public async executeAction(action: string, data: any, user?: IdentityProviderUserWithRoles, req?: Request): Promise { return await ActionService.getAction(this, action).executeWithCheckings(data, user, req); } /** * Get the middleware for the app * @param req The request object * @returns The middleware function */ public getInternalApiRoute() { return async (req: Request, res: Response, next: NextFunction) => { if (req.method === 'GET') { next(); return; } try { const result = await this.handleInternalApiRequest(req); if (result) { res.setHeader('Content-Type', 'application/json'); res.status(200).json(result); return; } else { res.status(404).json({ error: 'Not Found' }); return; } } catch (error) { if (error instanceof HttpError) { res.status(error.statusCode).json({ status: 'error', statusCode: error.statusCode, message: error.message }); return; } console.error('Internal API error:', error); res.status(500).json({ error: 'Internal Server Error' }); return; } } } public getIdpRoute() { return async (req: Request, res: Response) => { try { if (!this.externalIdentityProvider) { res.status(400).json({ error: 'No connected identity provider configured' }); return; } if (req.method !== 'GET') { res.status(405).json({ error: 'Method Not Allowed' }); return; } const type = req.params.type; if (!type) { res.status(400).json({ error: 'Bad Request' }); return; } switch (type) { case 'callback': { const searchParamsStr = req.url.split('?')[1] || ''; const searchParams = new URLSearchParams(searchParamsStr); const code = searchParams.get('code') || ''; const stateSearchParam = searchParams.get('state') || ''; if (!code) { res.status(400).json({ error: 'Bad Request: code is required' }); return; } const { accessToken } = await this.externalIdentityProvider.exchangeCodeForToken({ code, stateSearchParam, }); const storageKey = storageService.save(accessToken); const url = `${this.basePath}auth?storageKeyForAccessToken=${storageKey}`; res.redirect(url); return; }; case 'login': { const redirectUri = req.query.redirectUri as string || ''; if (!redirectUri) { res.status(400).json({ error: 'Bad Request: redirectUri is required' }); return; } const { redirectUri: finalUrl } = await this.externalIdentityProvider.getLoginUrl({ redirectUri, }); res.redirect(finalUrl); return; } default: { res.status(404).json({ error: 'Not Found' }); return; } } } catch (error) { console.error('IdP route error:', error); if (!res.headersSent) { res.status(500).json({ error: 'IdP route failed' }); } } }; } public getDownloadRoute() { return async (req: Request, res: Response) => { try { if (req.method !== 'GET') { res.status(405).json({ error: 'Method Not Allowed' }); return; } const operationId = req.params.operationId; if (!operationId) { res.status(400).json({ error: 'Bad Request' }); return; } const operation = this.exporter.getOperation(operationId); const dataSource = this.dataSources.find(ds => ds.name === operation?.dataSourceName); if (!operation || !dataSource) { res.status(404).json({ error: 'Not Found' }); return; } const dataSourceAdapter = dataSource.adapter as DataSourceAdapter | undefined; if (!dataSourceAdapter) { throw new Error(`Data source adapter for "${dataSource.name}" not found`); } const stream = await dataSourceAdapter.getTableRecordsStream( ...operation.parameters ); const filename = `export-${dayjs().format('YYYY-MM-DD-HH-mm-ss')}-${operationId}`; const headers = this.exporter.getHeadersByFormat(operation.format, filename); Object.entries(headers).forEach(([key, value]) => { res.setHeader(key, value); }); switch (operation.format) { case 'json': { this.exporter.convertToJSON(stream, res); break; }; case 'csv': { this.exporter.convertToCSV(stream, res); break; } case 'xlsx': { this.exporter.convertToXLSX(stream, res); break; } default: { throw new Error('Unsupported export format'); } }; } catch (error) { console.error('Export route error:', error); if (!res.headersSent) { res.status(500).json({ error: 'Export failed' }); } } }; } private async handleInternalApiRequest(request: Request): Promise<{ status: 'success' | 'error'; result?: any; error?: string; }> { try { const { isTokenValid, invalidTokenErrorMessage, user } = await this.ensureValidToken(request); const action = request.query.action as keyof InternalApiSchema | undefined; const actionData = request.body; if (!action) { throw new Error('Action not found in request'); } if (!isTokenValid && action && !(['getApp', 'initApp', 'login', 'getStorageValue'] as (keyof InternalApiSchema)[]).includes(action)) { throw new UnauthorizedError(`Invalid JWT token: ${invalidTokenErrorMessage}`); } return { status: 'success', result: await this.executeAction(action, actionData, user ?? undefined, request), }; } catch (error) { // If the error is an instance of HttpError, we can rethrow it if (error instanceof HttpError) { throw error; } console.error('Kottster API error:', error); return { status: 'error', error: error.message, }; } } /** * Define a custom controller * @param procedures The procedures * @returns The express request handler */ public defineCustomController>( procedures: T ): RequestHandler & { procedures: T } { const func: RequestHandler = async (req, res) => { const { isTokenValid, user, invalidTokenErrorMessage } = await this.ensureValidToken(req); if (!isTokenValid || !user) { res.status(401).json({ error: `Invalid JWT token: ${invalidTokenErrorMessage}` }); return; } const { action, input } = await req.body as RpcRequestBody; if (action !== 'custom') { res.status(400).json({ error: 'Invalid action for custom controller' }); return; } const { procedure, procedureInput } = input; const ctx: ProcedureContext = { user, req, }; if (procedure in procedures) { try { const result = await procedures[procedure](procedureInput, ctx); res.json({ status: 'success', result, }); return; } catch (error) { console.error(`Error executing procedure "${procedure}":`, error); res.status(500).json({ status: 'error', error: error.message, }); return; } } res.status(404).json({ error: `Procedure "${procedure}" not found` }); return; }; // Attach the procedures to the function for later reference (func as any).procedures = procedures; return func as RequestHandler & { procedures: T }; } /** * Define a dashboard controller * @param dashboardPageConfig The dashboard page config * @returns The express request handler */ public defineDashboardController(partialDashboardPageConfig: PartialDashboardPageConfig) { const func: RequestHandler = async (req, res) => { const { isTokenValid, user, invalidTokenErrorMessage } = await this.ensureValidToken(req); if (!isTokenValid || !user) { res.status(401).json({ error: `Invalid JWT token: ${invalidTokenErrorMessage}` }); return; } const page = (req as Request & { page?: Page }).page; if (!page || page.type !== 'dashboard') { res.status(404).json({ error: 'Specified page not found' }); return; } // Merge the partial config with the page config const dashboardPageConfig: DashboardPageConfig = { ...page.config, ...partialDashboardPageConfig as Partial, stats: [ ...(page.config.stats ?? []), ...(partialDashboardPageConfig.stats ?? []) ].reduce((acc, stat) => { const existingIndex = acc.findIndex(s => s.key === stat.key); if (existingIndex >= 0) { acc[existingIndex] = { ...acc[existingIndex], ...stat } as DashboardPageConfigStat; } else { acc.push(stat as DashboardPageConfigStat); } return acc; }, [] as NonNullable), cards: [ ...(page.config.cards ?? []), ...(partialDashboardPageConfig.cards ?? []) ].reduce((acc, card) => { const existingIndex = acc.findIndex(c => c.key === card.key); if (existingIndex >= 0) { acc[existingIndex] = { ...acc[existingIndex], ...card } as DashboardPageConfigCard; } else { acc.push(card as DashboardPageConfigCard); } return acc; }, [] as NonNullable) }; try { const { action, input } = await req.body as RpcRequestBody; let result: unknown; try { if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, page.allowedRoles, page.allowedRoleIds)) { throw new Error('You do not have access to this page'); } if (action === 'dashboard_getStatData') { const stat = dashboardPageConfig.stats?.find(s => s.key === input.statKey); if (!stat) { res.status(404).json({ error: `Specified stat "${input.statKey}" not found` }); return; } if (stat.fetchStrategy === 'rawSqlQuery') { if (!stat.dataSource) { throw new Error(`Data source for stat not specified`); } const dataSource = this.dataSources.find(ds => ds.name === stat.dataSource); if (!dataSource) { throw new Error(`Data source "${stat.dataSource}" not found`); } const dataSourceAdapter = dataSource.adapter as DataSourceAdapter | undefined; if (!dataSourceAdapter) { throw new Error(`Data source adapter for "${stat.dataSource}" not found`); } result = await dataSourceAdapter.getStatData(input, stat); } else if (stat.fetchStrategy === 'customFetch') { if (!stat.customDataFetcher) { // Fallback to default result if no custom fetcher is provided console.warn(`Custom data fetcher for stat "${stat.key}" not specified`); result = { value: 0, total: 0, } as DashboardPageGetStatDataResult; } else { result = await stat.customDataFetcher(input); } } } else if (action === 'dashboard_getCardData') { const card = dashboardPageConfig.cards?.find(c => c.key === input.cardKey); if (!card) { res.status(404).json({ error: `Specified card "${input.cardKey}" not found` }); return; } if (card.fetchStrategy === 'rawSqlQuery') { if (!card.dataSource) { throw new Error(`Data source for card not specified`); } const dataSource = this.dataSources.find(ds => ds.name === card.dataSource); if (!dataSource) { throw new Error(`Data source "${card.dataSource}" not found`); } const dataSourceAdapter = dataSource.adapter as DataSourceAdapter | undefined; if (!dataSourceAdapter) { throw new Error(`Data source adapter for "${card.dataSource}" not found`); } result = await dataSourceAdapter.getCardData(input, card); } else if (card.fetchStrategy === 'customFetch') { if (!card.customDataFetcher) { // Fallback to default result if no custom fetcher is provided console.warn(`Custom data fetcher for card "${card.key}" not specified`); result = { items: [], } as DashboardPageGetCardDataResult; } else { result = await card.customDataFetcher(input); } } } } catch (error) { throw new Error(error); } res.json({ status: 'success', result, }); } catch (error) { if (error instanceof Error && error.message === 'Unauthorized') { return new Response('Unauthorized', { status: 401 }); } console.error('Error executing dashboard RPC:', error); res.status(500).json({ status: 'error', error: error.message, }); return; } } return func; } /** * Define a table controller * @param dataSource The data source * @param pageSettings The page settings * @returns The express request handler */ public defineTableController>( partialTablePageConfig: PartialTablePageConfig, procedures?: T ): RequestHandler & { procedures: T } { const func: RequestHandler = async (req, res, next) => { const { isTokenValid, user, invalidTokenErrorMessage } = await this.ensureValidToken(req); if (!isTokenValid || !user) { res.status(401).json({ error: `Invalid JWT token: ${invalidTokenErrorMessage}` }); return; } const page = (req as Request & { page?: Page }).page; if (!page || page.type !== 'table') { res.status(404).json({ error: 'Specified page not found' }); return; } // Merge the partial config with the page config const tablePageConfig: TablePageConfig = { ...page.config, ...partialTablePageConfig as Partial, nested: { ...page.config.nested, ...Object.keys(partialTablePageConfig.nested || {}).reduce((acc, key) => { const tablePageNestedTableKey = transformStringToTablePageNestedTableKey(key); acc[key] = { // We need to pass these required properties for nested table config table: tablePageNestedTableKey[tablePageNestedTableKey.length - 1]?.table, fetchStrategy: 'databaseTable', ...page.config.nested?.[key], ...partialTablePageConfig.nested?.[key] as Partial, }; return acc; }, {} as Record) } }; try { // Check if specified data source exists const dataSource = this.dataSources.find(ds => ds.name === tablePageConfig.dataSource); if (!dataSource && (tablePageConfig.fetchStrategy === 'databaseTable' || tablePageConfig.fetchStrategy === 'rawSqlQuery')) { throw new Error(`Data source "${tablePageConfig.dataSource}" not found`); } const { action, input } = await req.body as RpcRequestBody; let result: unknown; // If the request is a custom one, handle it by the custom controller if (action === 'custom') { return this.defineCustomController(procedures as T)(req, res, next); } try { const dataSourceAdapter = dataSource?.adapter as DataSourceAdapter | undefined; const databaseSchema = dataSourceAdapter ? await dataSourceAdapter.getDatabaseSchema() : undefined; if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, page.allowedRoles, page.allowedRoleIds)) { throw new Error('You do not have access to this page'); } // If the table select action is used and fetch strategy is 'customFetch', we need to execute the custom query right away if (action === 'table_getRecords' && tablePageConfig.fetchStrategy === 'customFetch') { result = tablePageConfig.customDataFetcher ? await tablePageConfig.customDataFetcher(input) : { records: [], }; } else { if (!dataSource) { throw new Error(`Data source "${tablePageConfig.dataSource}" not found`); } if (!dataSourceAdapter) { throw new Error(`Data source adapter for "${tablePageConfig.dataSource}" not found`); } if (!databaseSchema) { throw new Error(`Database schema for "${tablePageConfig.dataSource}" not found`); } if (action === 'table_getRecords') { result = await dataSourceAdapter?.getTableRecords(tablePageConfig, input, databaseSchema); } else if (action === 'table_initiateRecordsExport') { const operationId = this.exporter.createOperation({ parameters: [ tablePageConfig, input, databaseSchema, ], dataSourceName: dataSource.name, format: input.format, }); result = { operationId } as TablePageInitiateRecordsExportResult; } else if (action === 'table_getRecord') { result = await dataSourceAdapter.getOneTableRecord(tablePageConfig, input, databaseSchema); } else if (action === 'table_createRecord') { if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, tablePageConfig.allowedRolesToInsert, tablePageConfig.allowedRoleIdsToInsert)) { throw new Error('You do not have permission to create records in this table'); } result = await dataSourceAdapter.insertTableRecord(tablePageConfig, input, databaseSchema); } else if (action === 'table_updateRecord') { if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, tablePageConfig.allowedRolesToUpdate, tablePageConfig.allowedRoleIdsToUpdate)) { throw new Error('You do not have permission to update records in this table'); } result = await dataSourceAdapter.updateTableRecords(tablePageConfig, input, databaseSchema); } else if (action === 'table_deleteRecord') { if (this.stage === Stage.production && !checkUserForRoles(user.id, user.roles, tablePageConfig.allowedRolesToDelete, tablePageConfig.allowedRoleIdsToDelete)) { throw new Error('You do not have permission to delete records in this table'); } result = await dataSourceAdapter.deleteTableRecords(tablePageConfig, input, databaseSchema); }; }; } catch (error) { throw new Error(error); } res.json({ status: 'success', result, }); } catch (error) { if (error instanceof Error && error.message === 'Unauthorized') { return new Response('Unauthorized', { status: 401 }); } console.error('Error executing table RPC:', error); res.status(500).json({ status: 'error', error: error.message, }); return; } }; // Attach the procedures to the function for later reference (func as any).procedures = procedures; return func as RequestHandler & { procedures: T }; }; private async ensureValidToken(request: Request): Promise { try { // If a custom token validation function is provided, use it if (this.customEnsureValidToken) { return this.customEnsureValidToken(request); } const token = request.get('authorization')?.replace('Bearer ', ''); if (!token) { return { isTokenValid: false, user: null, invalidTokenErrorMessage: 'Invalid JWT token: token not passed' }; } if (this.externalIdentityProvider) { const { user } = await this.externalIdentityProvider.getUserData({ accessToken: token }); return { isTokenValid: true, user, }; } else if (this.identityProvider) { const user = await this.identityProvider.verifyTokenAndGetUser(token); const userRoles = await this.identityProvider.getUserRoles(user.id); const extendedUser: IdentityProviderUserWithRoles = { ...user, roles: userRoles, }; // If a post-auth middleware is provided, call it if (this.postAuthMiddleware) { await this.postAuthMiddleware(extendedUser, request); } return { isTokenValid: true, user: extendedUser, }; } else { throw new Error('No identity provider configured for the app'); } } catch (error) { return { isTokenValid: false, user: null, invalidTokenErrorMessage: error.message }; } } public getDataSources() { return this.dataSources; } public async listen() { return createServer({ app: this, }); } }