UNPKG

@flavoai/fastfold

Version:

Flavo frontend package

1,061 lines â€ĸ 78 kB
// ============================================================================ // FASTFOLD - Zero-boilerplate backend for React apps // ============================================================================ // Core types export * from './types'; // AI module export { setupAIRoutes } from './ai'; // Security API export { Security, SecurityEnforcer } from './security'; // Database adapters (legacy config only; use quickStart with drizzle for PGlite) export { createDatabaseAdapter } from './database/adapters'; // Server export { FastfoldServer, createFastfoldServer } from './server'; // CRUD generator (for advanced usage) export { CrudGenerator } from './crud/generator'; // Server Observability (unified error tracking) export { observabilityErrorMiddleware, observabilityRequestMiddleware, initObservability, getObservabilityConfig, trackError, trackAIError, trackIntegrationError } from './server/observability'; // In-App Analytics (buffered aggregation) export { AnalyticsBufferManager, initializeAnalytics, getAnalyticsManager, trackEvents, flushAnalytics } from './server/analytics'; // AI Logger export { aiLogger } from './lib/aiLogger'; // ============================================================================ // CONVENIENCE EXPORTS FOR COMMON USAGE // ============================================================================ import express from 'express'; import path from 'path'; import fs from 'fs'; import cors from 'cors'; import jwt from 'jsonwebtoken'; import { setupAIRoutes } from './ai'; import { createAIHooksProcessor } from './ai/hooks'; import { getSensitiveFields, hashSensitiveFields, redactSensitiveFields, isSensitiveFieldName } from './security/sensitive'; import { Security, SecurityEnforcer } from './security'; import { sql } from 'drizzle-orm'; import { DrizzleAdapter } from './database/adapters/drizzle'; import { RequestLogger } from './lib/logger'; import { syncFlavoUserToDb } from './auth/flavo-sync'; // ============================================================================ // STUDIO AUTH RESOLVER // ============================================================================ // // Resolves the password that protects `/studio/api/*` endpoints. Flavo // injects FASTFOLD_STUDIO_PASSWORD (or the legacy STUDIO_PASSWORD) at // container-build time so generated apps are secure by default without // any developer action. Config-level `studio.auth.password` still wins // when set explicitly. Weak/empty values are rejected in production and // warned against in development. // ============================================================================ const WEAK_STUDIO_PASSWORDS = new Set([ 'admin', 'password', 'changeme', 'default', 'test', 'fastfold', 'studio', ]); const MIN_STUDIO_PASSWORD_LENGTH = 12; function classifyStudioPassword(pw) { if (!pw) return 'missing'; const trimmed = pw.trim(); if (!trimmed) return 'missing'; if (trimmed.length < MIN_STUDIO_PASSWORD_LENGTH) return 'weak'; if (WEAK_STUDIO_PASSWORDS.has(trimmed.toLowerCase())) return 'weak'; return 'ok'; } function resolveStudioAuth(studio, isProduction) { const candidates = [ { pw: studio?.auth?.password, source: 'config' }, { pw: process.env.FASTFOLD_STUDIO_PASSWORD, source: 'env:FASTFOLD_STUDIO_PASSWORD' }, { pw: process.env.STUDIO_PASSWORD, source: 'env:STUDIO_PASSWORD' }, ]; for (const { pw, source } of candidates) { const verdict = classifyStudioPassword(pw); if (verdict === 'missing') continue; if (verdict === 'weak') { if (isProduction) { console.warn(`[Fastfold] Studio password from ${source} rejected in production ` + `(must be ${MIN_STUDIO_PASSWORD_LENGTH}+ chars and not a common default).`); continue; } console.warn(`[Fastfold] Studio password from ${source} is weak — OK for dev, ` + `but production will reject it. Set a longer random secret.`); } return { enabled: studio?.auth?.enabled !== false, password: pw.trim(), source, }; } return null; } /** * Main Fastfold class for easy setup */ export class Fastfold { static Security = Security; /** * Quick start implementation */ static async quickStart(config, port = 3001) { const configAny = config; const resolvedPort = configAny.port ?? port; if ('drizzle' in config) { return await this.startWithDrizzle(config, resolvedPort); } else { return await this.startLegacy(config, resolvedPort); } } /** * Start server with Drizzle integration */ static async startWithDrizzle(config, port) { const configAny = config; const endpoints = configAny.endpoints ?? configAny.setup; const { drizzle: drizzleConfig, tables, auth, uploads, rateLimit, hooks, static: staticConfig, middleware, logging, studio, ai, aiHooks, cors: corsConfig } = configAny; // Create DrizzleAdapter const adapter = new DrizzleAdapter(drizzleConfig.db, drizzleConfig.schema); // Write security metadata to _fastfold_meta table for shared backend service await this.writeSecurityMeta(drizzleConfig.db, tables); // Create Express server const server = await this.createDrizzleServer({ adapter, drizzleDb: drizzleConfig.db, drizzleSchema: drizzleConfig.schema, tables, auth, uploads, rateLimit, endpoints, hooks, port, staticFrontend: staticConfig, middleware, logging, studio, ai, aiHooks, corsConfig }); // Execute server start hook if (hooks?.onServerStart) { await hooks.onServerStart(server); } await server.start(port); return adapter; } /** * Write security metadata to _fastfold_meta table for the shared backend service. * This allows the shared multi-tenant FastFold service to enforce per-app security rules * without needing to import/parse the app's server.ts file. */ static async writeSecurityMeta(db, tables) { if (!tables || !db) return; try { await db.execute(sql ` CREATE TABLE IF NOT EXISTS _fastfold_meta ( key TEXT PRIMARY KEY, value JSONB NOT NULL, updated_at TIMESTAMP DEFAULT NOW() ) `); const securityConfig = {}; for (const [tableName, config] of Object.entries(tables)) { if (config.security.type === 'custom') { console.warn(`[fastfold-meta] Table "${tableName}" uses custom security rule — treating as "authenticated" in shared service`); securityConfig[tableName] = { type: 'authenticated', operations: config.operations || ['create', 'read', 'update', 'delete'], }; } else { securityConfig[tableName] = { type: config.security.type, ownerField: config.security.ownerField, operations: config.operations || ['create', 'read', 'update', 'delete'], }; } } const value = JSON.stringify(securityConfig); await db.execute(sql ` INSERT INTO _fastfold_meta (key, value, updated_at) VALUES ('security_config', ${value}::jsonb, NOW()) ON CONFLICT (key) DO UPDATE SET value = ${value}::jsonb, updated_at = NOW() `); console.log(`[fastfold-meta] Wrote security config for ${Object.keys(securityConfig).length} tables`); } catch (err) { console.warn('[fastfold-meta] Failed to write security metadata:', err.message); } } /** * Legacy start method for backward compatibility */ static async startLegacy(config, port) { // Convert legacy config to new format for internal use const legacyTables = {}; for (const [tableName, tableDef] of Object.entries(config.tables)) { legacyTables[tableName] = { security: tableDef.security, operations: ['create', 'read', 'update', 'delete'] // All operations by default }; } // Create a minimal server for legacy support // This would use the old database adapters throw new Error('Legacy mode not yet implemented in new version. Please migrate to Drizzle.'); } /** * Create FastfoldServer with Drizzle adapter */ static async createDrizzleServer(options) { const { adapter, drizzleDb, drizzleSchema, tables, auth, endpoints, hooks, staticFrontend, middleware, logging, studio, ai, aiHooks, corsConfig } = options; // Create AI hooks processor if configured const aiHooksProcessor = createAIHooksProcessor(aiHooks, ai); const isProduction = process.env.NODE_ENV === 'production'; const safeError = (error) => { if (!isProduction) return error.message; console.error('[Fastfold] Internal error:', error); return 'Internal server error'; }; // Create Express app const app = express(); // Trust reverse proxies so req.protocol and req.get('host') reflect // the external URL (e.g. https://{appId}.superbuilder.app) rather than // the internal container address (e.g. http://localhost:3001). app.set('trust proxy', true); // Setup CORS with configurable origins const isDev = process.env.NODE_ENV !== 'production'; const corsOrigins = corsConfig?.origins ?? (isDev ? ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'] : []); // Setup middleware app.use(cors({ origin: corsOrigins.length > 0 ? corsOrigins : false, credentials: true, }), express.json({ limit: '5mb' }), express.urlencoded({ extended: true, limit: '5mb' })); // Setup request logging if enabled let logger; if (logging?.enabled) { logger = new RequestLogger(logging); app.use(logger.getMiddleware()); console.log(`📝 Request logging enabled: ${logging.logFilePath || './request-logs.txt'}`); } // Attach any additional middleware provided by user (after core parsers, before auth) const extraMiddleware = middleware || undefined; if (Array.isArray(extraMiddleware)) { for (const mw of extraMiddleware) { if (typeof mw === 'function') { app.use(mw); } } } // Setup authentication middleware if provided if (auth) { const flavoSettings = auth.providers?.flavo; const customSettings = auth.providers?.custom; if (flavoSettings) { // Flavo delegated auth: verify JWTs locally using the app's own public key. // The JWT is signed by Flavo with a per-app private key after OAuth. const flavoAppId = flavoSettings.appId || process.env.VITE_FASTFOLD_APP_ID || process.env.FLAVO_CONVERSATION_ID; const flavoGatewayUrl = (process.env.FLAVO_GATEWAY_URL || 'https://flavo.ai').replace(/\/$/, ''); if (!flavoAppId) { throw new Error('[Fastfold] providers.flavo.appId is required for Flavo auth, or set VITE_FASTFOLD_APP_ID / FLAVO_CONVERSATION_ID env var.'); } const jwtPublicKeyPem = process.env.FLAVO_PUBLIC_KEY?.replace(/\\n/g, '\n'); if (!jwtPublicKeyPem) { throw new Error('[Fastfold] FLAVO_PUBLIC_KEY env var is required for Flavo auth. ' + 'This should be injected automatically by the Flavo platform.'); } const flavoUserTable = flavoSettings.userTable; const flavoSyncEnabled = !!(flavoUserTable && drizzleDb && drizzleSchema); app.use(async (req, _res, next) => { const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7); try { const payload = jwt.verify(token, jwtPublicKeyPem, { algorithms: ['RS256'], }); req.user = { id: payload.email, email: payload.email, displayName: payload.displayName || '', avatar: payload.avatar || '', authProvider: payload.provider || '', role: 'user', meta: {}, }; if (flavoSyncEnabled) { const localId = await syncFlavoUserToDb(drizzleDb, drizzleSchema, flavoUserTable, req.user); if (localId !== undefined) { req.user.localId = localId; } } } catch (_err) { // Invalid or expired token — continue without user } } next(); }); // Server-side login initiation: POST the FLAVO_APP_TOKEN to Flavo's // /initiate endpoint (server-to-server), then redirect the browser to // the Flavo login page where the user picks their OAuth provider. const flavoAppToken = process.env.FLAVO_APP_TOKEN; const flavoInitiateUrl = `${flavoGatewayUrl}/api/app/auth/initiate`; app.get('/api/auth/login', async (req, res) => { if (!flavoAppToken) { return res.status(500).json({ success: false, error: 'FLAVO_APP_TOKEN env var is not set. Cannot initiate login.', }); } const redirectOrigin = `${req.protocol}://${req.get('host')}`; try { const response = await fetch(flavoInitiateUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appId: flavoAppId, appToken: flavoAppToken, redirectOrigin }), }); if (!response.ok) { const fallbackMessage = `Failed to initiate login with Flavo (status ${response.status})`; let errorMessage = fallbackMessage; let errorCode; try { const body = await response.json(); if (typeof body.error === 'string' && body.error.trim().length > 0) { errorMessage = body.error; } if (typeof body.code === 'string' && body.code.trim().length > 0) { errorCode = body.code; } console.error(`[Fastfold] Failed to initiate login (${response.status}):`, body); } catch { console.error(`[Fastfold] Failed to initiate login (${response.status}) with non-JSON response.`); } const statusCode = response.status >= 400 && response.status <= 599 ? response.status : 502; const payload = { success: false, error: errorMessage, upstreamStatus: response.status, }; if (errorCode) { payload.code = errorCode; } return res.status(statusCode).json(payload); } const { redirectUrl } = await response.json(); res.redirect(redirectUrl); } catch (err) { console.error('[Fastfold] Error contacting Flavo for login initiation:', err); return res.status(502).json({ success: false, error: 'Unable to reach Flavo for login initiation', }); } }); app.get('/api/auth/me', (req, res) => { if (req.user) { return res.json({ user: req.user }); } return res.status(401).json({ error: 'Not authenticated' }); }); } else if (customSettings) { // HS256 symmetric secret auth (custom provider) const secret = customSettings.secret || auth.secret; if (!secret) { throw new Error('[Fastfold] providers.custom.secret is required when using custom auth. ' + 'Set a strong random secret (e.g. via an environment variable), ' + 'or use providers: { flavo: { ... } } for delegated Flavo auth.'); } app.use((req, _res, next) => { const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7); try { const decoded = jwt.verify(token, secret); } catch (err) { // Invalid token - continue without user } } next(); }); } } // Simple in-memory rate limiter for CRUD routes const crudRateLimitStore = {}; const crudRateLimitWindowMs = 60_000; const crudRateLimitMax = 100; const crudRateLimiter = (req, res, next) => { const ip = req.ip || req.connection?.remoteAddress || 'unknown'; const now = Date.now(); if (!crudRateLimitStore[ip] || now > crudRateLimitStore[ip].resetTime) { crudRateLimitStore[ip] = { count: 1, resetTime: now + crudRateLimitWindowMs }; return next(); } crudRateLimitStore[ip].count++; if (crudRateLimitStore[ip].count > crudRateLimitMax) { const retryAfter = Math.ceil((crudRateLimitStore[ip].resetTime - now) / 1000); res.setHeader('Retry-After', retryAfter.toString()); return res.status(429).json({ success: false, error: 'Too many requests. Please try again later.' }); } next(); }; app.use('/api', crudRateLimiter); // Setup CRUD routes for each table const tableNames = adapter.getTableNames().filter(name => tables[name]); for (const tableName of tableNames) { const tableConfig = tables[tableName]; const ops = tableConfig.operations || ['create', 'read', 'update', 'delete']; const sensitiveFields = getSensitiveFields(adapter.getTable(tableName)); // GET /api/tablename - List all if (ops.includes('read')) { app.get(`/api/${tableName}`, async (req, res) => { try { const studioBypass = typeof process.env.STUDIO_BYPASS_SECRET === 'string' && process.env.STUDIO_BYPASS_SECRET.length > 0 && req.get('X-Fastfold-Studio-Token') === process.env.STUDIO_BYPASS_SECRET; const securityRule = tableConfig.security; if (securityRule && !studioBypass) { const context = { user: req.user, operation: 'read', tableName }; const hasAccess = await SecurityEnforcer.checkAccess(securityRule, context); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Forbidden' }); } } // Parse query parameters let params = req.query.params ? JSON.parse(req.query.params) : req.query; // For owner-based security, inject where clause to filter by owner if (securityRule?.type === 'owner' && req.user) { const ownerField = securityRule.ownerField || 'userId'; params = { ...params, where: { ...params.where, [ownerField]: req.user.id } }; } // Check if 'with' parameter exists to determine which query method to use const hasRelations = params.with && Object.keys(params.with).length > 0; const results = hasRelations ? await adapter.queryWithRelations(tableName, params) : await adapter.query(tableName, params); const safeResults = sensitiveFields.size > 0 ? results.map((r) => redactSensitiveFields(r, sensitiveFields)) : results; res.json({ success: true, data: safeResults }); } catch (error) { res.status(500).json({ success: false, error: safeError(error) }); } }); // GET /api/tablename/:id - Get by ID app.get(`/api/${tableName}/:id`, async (req, res) => { try { // Parse query parameters for potential 'with' relations const params = req.query.params ? JSON.parse(req.query.params) : req.query; const queryParams = { where: { id: parseInt(req.params.id) }, limit: 1, ...params }; // Check if 'with' parameter exists const hasRelations = queryParams.with && Object.keys(queryParams.with).length > 0; const result = hasRelations ? await adapter.queryWithRelations(tableName, queryParams) : await adapter.query(tableName, queryParams); const item = result[0]; if (!item) { return res.status(404).json({ success: false, error: 'Not found' }); } const studioBypass = typeof process.env.STUDIO_BYPASS_SECRET === 'string' && process.env.STUDIO_BYPASS_SECRET.length > 0 && req.get('X-Fastfold-Studio-Token') === process.env.STUDIO_BYPASS_SECRET; const securityRule = tableConfig.security; if (securityRule && !studioBypass) { const context = { user: req.user, operation: 'read', tableName, existingData: item }; const hasAccess = await SecurityEnforcer.checkAccess(securityRule, context); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Forbidden' }); } } const safeItem = sensitiveFields.size > 0 ? redactSensitiveFields(item, sensitiveFields) : item; res.json({ success: true, data: safeItem }); } catch (error) { res.status(500).json({ success: false, error: safeError(error) }); } }); } // POST /api/tablename - Create if (ops.includes('create')) { app.post(`/api/${tableName}`, async (req, res) => { try { // Validate request body is not empty if (!req.body || typeof req.body !== 'object' || Object.keys(req.body).length === 0) { return res.status(400).json({ success: false, error: 'Request body cannot be empty. Provide at least one field to create.' }); } let data = req.body; const context = { user: req.user, operation: 'create', data, tableName }; const studioBypass = typeof process.env.STUDIO_BYPASS_SECRET === 'string' && process.env.STUDIO_BYPASS_SECRET.length > 0 && req.get('X-Fastfold-Studio-Token') === process.env.STUDIO_BYPASS_SECRET; const securityRule = tableConfig.security; if (securityRule && !studioBypass) { const hasAccess = await SecurityEnforcer.checkAccess(securityRule, context); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Forbidden' }); } } // Execute beforeCreate hook if defined if (hooks?.beforeCreate?.[tableName]) { data = await hooks.beforeCreate[tableName](data, context); } // Execute AI hooks if configured if (aiHooksProcessor?.hasHooksForTable(tableName)) { data = await aiHooksProcessor.processBeforeWrite(tableName, data, context); } if (sensitiveFields.size > 0) { data = hashSensitiveFields(data, sensitiveFields); } const result = await adapter.create(tableName, data); // Execute afterCreate hook if defined if (hooks?.afterCreate?.[tableName]) { await hooks.afterCreate[tableName](result, context); } const safeResult = sensitiveFields.size > 0 ? redactSensitiveFields(result, sensitiveFields) : result; res.status(201).json({ success: true, data: safeResult }); } catch (error) { res.status(500).json({ success: false, error: safeError(error) }); } }); } // PUT /api/tablename/:id - Update if (ops.includes('update')) { app.put(`/api/${tableName}/:id`, async (req, res) => { try { // Validate request body is not empty if (!req.body || typeof req.body !== 'object' || Object.keys(req.body).length === 0) { return res.status(400).json({ success: false, error: 'Request body cannot be empty. Provide at least one field to update. Use format: { id, data: { ...fields } } on the client.' }); } const id = parseInt(req.params.id); let data = req.body; // Get existing record for hooks and security check const existing = await adapter.findById(tableName, id); const context = { user: req.user, operation: 'update', data, existingData: existing, tableName }; const studioBypass = typeof process.env.STUDIO_BYPASS_SECRET === 'string' && process.env.STUDIO_BYPASS_SECRET.length > 0 && req.get('X-Fastfold-Studio-Token') === process.env.STUDIO_BYPASS_SECRET; const securityRule = tableConfig.security; if (securityRule && !studioBypass) { const hasAccess = await SecurityEnforcer.checkAccess(securityRule, context); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Forbidden' }); } } // Execute beforeUpdate hook if defined if (hooks?.beforeUpdate?.[tableName]) { data = await hooks.beforeUpdate[tableName](data, existing, context); } // Execute AI hooks if configured if (aiHooksProcessor?.hasHooksForTable(tableName)) { data = await aiHooksProcessor.processBeforeWrite(tableName, data, context); } if (sensitiveFields.size > 0) { data = hashSensitiveFields(data, sensitiveFields); } const result = await adapter.update(tableName, id, data); // Execute afterUpdate hook if defined if (hooks?.afterUpdate?.[tableName]) { await hooks.afterUpdate[tableName](result, context); } const safeResult = sensitiveFields.size > 0 ? redactSensitiveFields(result, sensitiveFields) : result; res.json({ success: true, data: safeResult }); } catch (error) { res.status(500).json({ success: false, error: safeError(error) }); } }); } // DELETE /api/tablename/:id - Delete if (ops.includes('delete')) { app.delete(`/api/${tableName}/:id`, async (req, res) => { try { const id = parseInt(req.params.id); const studioBypass = typeof process.env.STUDIO_BYPASS_SECRET === 'string' && process.env.STUDIO_BYPASS_SECRET.length > 0 && req.get('X-Fastfold-Studio-Token') === process.env.STUDIO_BYPASS_SECRET; const securityRule = tableConfig.security; if (securityRule && !studioBypass) { const existing = await adapter.findById(tableName, id); const context = { user: req.user, operation: 'delete', tableName, existingData: existing }; const hasAccess = await SecurityEnforcer.checkAccess(securityRule, context); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Forbidden' }); } } await adapter.delete(tableName, id); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: safeError(error) }); } }); } } // Add /internal-logs endpoint for frontend logging (dev only) if (logging?.enabled) { app.post('/internal-logs', (req, res) => { try { const { method, url, headers, body, timestamp } = req.body; if (!method || !url) { return res.status(400).json({ success: false, error: 'Missing required fields: method and url' }); } logger?.logFrontendRequest({ method, url, headers, body, timestamp }); res.json({ success: true, message: 'Log recorded' }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); } // Add basic endpoints app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString() }); }); // Add API docs endpoint app.get('/docs', (req, res) => { try { const docsHtml = this.generateDrizzleDocs(adapter, tables); res.setHeader('Content-Type', 'text/html'); res.send(docsHtml); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.get('/docs/json', (req, res) => { try { const docsJson = this.generateDrizzleDocsJson(adapter, tables); res.json(docsJson); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Setup custom endpoints if (endpoints) { endpoints(app); } // Setup AI endpoints if enabled if (ai?.enabled) { setupAIRoutes(app, ai); } // Setup Studio API endpoints (dev-only database visualization) if (studio?.enabled) { const resolved = resolveStudioAuth(studio, isProduction); if (!resolved) { console.warn('[Fastfold] Studio routes not registered: no acceptable password. ' + 'Provide studio.auth.password in config, or set ' + 'FASTFOLD_STUDIO_PASSWORD / STUDIO_PASSWORD env var ' + '(>=12 chars, not a common default like "admin").'); } else { console.log(`[Fastfold] Studio auth resolved from ${resolved.source}`); this.setupStudioRoutes(app, adapter, tables, { ...studio, auth: { enabled: resolved.enabled, password: resolved.password }, }); } } // Serve static frontend (supports multiple mounts) const staticCfg = staticFrontend ?? (isProduction ? { directory: path.resolve(process.cwd(), 'dist'), urlPath: '/', spaFallback: true, indexFile: 'index.html', } : undefined); const mounts = !staticCfg ? [] : Array.isArray(staticCfg) ? staticCfg : [staticCfg]; for (const mount of mounts) { const directory = mount.directory || path.resolve(process.cwd(), 'dist'); const urlPath = mount.urlPath || '/'; const indexFile = mount.indexFile || 'index.html'; const staticOptions = mount.staticOptions || { index: false }; if (!fs.existsSync(directory)) { console.log(`â„šī¸ Static mount skipped: directory not found -> ${directory}`); continue; } // Mount static assets if (urlPath === '/') { app.use(express.static(directory, staticOptions)); } else { app.use(urlPath, express.static(directory, staticOptions)); } // SPA fallback if requested const spa = mount.spaFallback ?? (urlPath === '/'); if (spa) { const defaultExcludes = ['/api', '/docs']; const excludes = mount.excludePaths && mount.excludePaths.length > 0 ? mount.excludePaths : defaultExcludes; const shouldExclude = (reqPath) => { return excludes.some((p) => { if (typeof p === 'string') return reqPath.startsWith(p); return p.test(reqPath); }); }; const fallbackHandler = (req, res, next) => { if (shouldExclude(req.path)) return next(); const filePath = path.join(directory, indexFile); res.sendFile(filePath, (err) => { if (err && !res.headersSent) { res.status(500).json({ error: 'Failed to serve index' }); } }); }; if (urlPath === '/') { app.get('*', fallbackHandler); } else { app.get(`${urlPath}*`, fallbackHandler); } console.log(`đŸ—‚ī¸ Static mount: ${directory} -> ${urlPath} (SPA fallback enabled)`); } else { console.log(`đŸ—‚ī¸ Static mount: ${directory} -> ${urlPath}`); } } // Create server object const server = { adapter, tables, app, async start(serverPort) { console.log(`🚀 Fastfold server starting on port ${serverPort}`); console.log('📊 Auto-generated CRUD endpoints:'); for (const tableName of tableNames) { const tableConfig = tables[tableName]; const ops = tableConfig.operations || ['create', 'read', 'update', 'delete']; console.log(` ${tableName}:`); if (ops.includes('read')) { console.log(` GET /api/${tableName} - List ${tableName}`); console.log(` GET /api/${tableName}/:id - Get ${tableName} by ID`); } if (ops.includes('create')) { console.log(` POST /api/${tableName} - Create ${tableName}`); } if (ops.includes('update')) { console.log(` PUT /api/${tableName}/:id - Update ${tableName}`); } if (ops.includes('delete')) { console.log(` DELETE /api/${tableName}/:id - Delete ${tableName}`); } } if (endpoints) { console.log('đŸŽ¯ Custom endpoints: Available via endpoints function'); } // Actually start the HTTP server (0.0.0.0 for Docker/container networking) app.listen(serverPort, '0.0.0.0', () => { console.log('✅ Fastfold server ready!'); }); }, getApp() { return this.app; }, async shutdown() { console.log('🛑 Shutting down Fastfold server...'); } }; return server; } /** * Generate HTML docs for Drizzle system */ static generateDrizzleDocs(adapter, tables) { const { ApiExplorer } = require('./docs'); // Generate documentation data const docs = this.generateDrizzleDocsJson(adapter, tables); // Create mock doc generator to use existing ApiExplorer const mockDocGenerator = { generateDocumentation: () => docs }; const explorer = new ApiExplorer(mockDocGenerator); return explorer.generateHtml(); } /** * Generate JSON docs for Drizzle system */ static generateDrizzleDocsJson(adapter, tables) { const tableNames = adapter.getTableNames().filter(name => tables[name]); // Generate table documentation const tablesDocs = {}; const endpoints = []; for (const tableName of tableNames) { const tableConfig = tables[tableName]; const ops = tableConfig.operations || ['create', 'read', 'update', 'delete']; // Get schema from Drizzle table definition let schema; try { const table = adapter.getTable(tableName); schema = this.extractDrizzleSchema(table); } catch (error) { // Fallback to mock schema if table not found schema = this.getMockTableSchema(tableName); } // Add table documentation tablesDocs[tableName] = { schema, security: this.getSecurityDescription(tableConfig.security) }; // Generate endpoints for this table endpoints.push(...this.generateDrizzleEndpoints(tableName, schema, ops)); } // Add health endpoint endpoints.unshift({ method: 'GET', path: '/health', description: 'Health check endpoint', responses: [ { status: 200, description: 'Server is healthy', schema: { type: 'object', properties: { status: { type: 'string', example: 'healthy' }, timestamp: { type: 'string', example: '2024-01-01T00:00:00.000Z' } } } } ] }); return { info: { title: 'Fastfold API with Drizzle', description: 'Auto-generated CRUD API with Drizzle ORM', version: '1.0.0', baseUrl: 'http://localhost:3001' }, tables: tablesDocs, endpoints }; } /** * Extract schema from Drizzle table definition */ static extractDrizzleSchema(table) { const schema = {}; try { // Extract from Drizzle table - use JS property names (keys), not DB column names if (table) { // Iterate through table properties to find column definitions for (const [jsPropertyName, value] of Object.entries(table)) { // Skip internal Drizzle properties if (jsPropertyName === '_' || jsPropertyName === 'name' || !value) continue; const column = value; // Check if this is a column definition if (column && typeof column === 'object' && (column.dataType || column.columnType || column.config)) { const dataType = column.dataType || column.columnType || 'string'; const mappedType = this.mapDrizzleType(dataType); schema[jsPropertyName] = isSensitiveFieldName(jsPropertyName) ? 'sensitive' : mappedType; } } } // Fallback: If extraction failed, use mock schema if (Object.keys(schema).length === 0) { return this.getMockTableSchema(table.name || table); } } catch (error) { console.error('Error extracting schema:', error); return this.getMockTableSchema(table.name || table); } return schema; } /** * Get mock table schema for demo purposes */ static getMockTableSchema(tableName) { // Extract table name if it's an object with a name property const name = typeof tableName === 'string' ? tableName : tableName?.name || 'unknown'; // Return predefined schemas for known tables const mockSchemas = { users: { id: 'number', name: 'string', email: 'string', role: 'string', createdAt: 'date' }, posts: { id: 'number', title: 'string', content: 'text', published: 'boolean', authorId: 'number', createdAt: 'date' }, comments: { id: 'number', text: 'text', authorId: 'number', postId: 'number', createdAt: 'date' } }; return mockSchemas[name] || { id: 'number', name: 'string', createdAt: 'date' }; } /** * Map Drizzle data types to simple types */ static mapDrizzleType(drizzleType) { const type = drizzleType.toLowerCase(); if (type.includes('text') || type.includes('varchar')) return 'string'; if (type.includes('int') || type.includes('number')) return 'number'; if (type.includes('bool')) return 'boolean'; if (type.includes('date') || type.includes('time')) return 'date'; if (type.includes('json')) return 'json'; return 'string'; } /** * Generate endpoints documentation for a table */ static generateDrizzleEndpoints(tableName, schema, operations) { const endpoints = []; const basePath = `/api/${tableName}`; // GET /api/:table - List records if (operations.includes('read')) { endpoints.push({ method: 'GET', path: basePath, description: `List all ${tableName} records`, responses: [ { status: 200, description: 'List of records', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, data: { type: 'array', items: this.schemaToJsonSchema(schema) } } } } ] }); // GET /api/:table/:id - Get single record endpoints.push({ method: 'GET', path: `${basePath}/{id}`, description: `Get a specific ${tableName} record by ID`, parameters: [ { name: 'id', in: 'path', required: true, type: 'number', description: 'Record ID' } ], responses: [ { status: 200, description: 'Single record', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, data: this.schemaToJsonSchema(schema) } } }, { status: 404, description: 'Record not found' } ] }); } // POST /api/:table - Create record if (operations.includes('create')) { endpoints.push({ method: 'POST', path: basePath, description: `Create a new ${tableName} record`, requestBody: { description: `${tableName} data to create`, schema: this.schemaToJsonSchema(schema) }, responses: [ { status: 201, description: 'Record created successfully', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, data: this.schemaToJsonSchema(schema) } } }, { status: 400, description: 'Validation error' } ] }); } // PUT /api/:table/:id - Update record if (operations.includes('upda