@flavoai/fastfold
Version:
Flavo frontend package
1,061 lines âĸ 78 kB
JavaScript
// ============================================================================
// 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