chadstart 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.dockerignore +10 -0
  2. package/.env.example +46 -0
  3. package/.github/workflows/browser-test.yml +34 -0
  4. package/.github/workflows/docker-publish.yml +54 -0
  5. package/.github/workflows/docs.yml +31 -0
  6. package/.github/workflows/npm-chadstart.yml +27 -0
  7. package/.github/workflows/npm-sdk.yml +38 -0
  8. package/.github/workflows/test.yml +85 -0
  9. package/.weblate +9 -0
  10. package/Dockerfile +23 -0
  11. package/README.md +348 -0
  12. package/admin/index.html +2802 -0
  13. package/admin/login.html +207 -0
  14. package/chadstart.example.yml +416 -0
  15. package/chadstart.schema.json +367 -0
  16. package/chadstart.yaml +53 -0
  17. package/cli/cli.js +295 -0
  18. package/core/api-generator.js +606 -0
  19. package/core/auth.js +298 -0
  20. package/core/db.js +384 -0
  21. package/core/entity-engine.js +166 -0
  22. package/core/error-reporter.js +132 -0
  23. package/core/file-storage.js +97 -0
  24. package/core/functions-engine.js +353 -0
  25. package/core/openapi.js +171 -0
  26. package/core/plugin-loader.js +92 -0
  27. package/core/realtime.js +93 -0
  28. package/core/schema-validator.js +50 -0
  29. package/core/seeder.js +231 -0
  30. package/core/telemetry.js +119 -0
  31. package/core/upload.js +372 -0
  32. package/core/workers/php_worker.php +19 -0
  33. package/core/workers/python_worker.py +33 -0
  34. package/core/workers/ruby_worker.rb +21 -0
  35. package/core/yaml-loader.js +64 -0
  36. package/demo/chadstart.yaml +178 -0
  37. package/demo/docker-compose.yml +31 -0
  38. package/demo/functions/greet.go +39 -0
  39. package/demo/functions/hello.cpp +18 -0
  40. package/demo/functions/hello.py +13 -0
  41. package/demo/functions/hello.rb +10 -0
  42. package/demo/functions/onTodoCreated.js +13 -0
  43. package/demo/functions/ping.sh +13 -0
  44. package/demo/functions/stats.js +22 -0
  45. package/demo/public/index.html +522 -0
  46. package/docker-compose.yml +17 -0
  47. package/docs/access-policies.md +155 -0
  48. package/docs/admin-ui.md +29 -0
  49. package/docs/angular.md +69 -0
  50. package/docs/astro.md +71 -0
  51. package/docs/auth.md +160 -0
  52. package/docs/cli.md +56 -0
  53. package/docs/config.md +127 -0
  54. package/docs/crud.md +627 -0
  55. package/docs/deploy.md +113 -0
  56. package/docs/docker.md +59 -0
  57. package/docs/entities.md +385 -0
  58. package/docs/functions.md +196 -0
  59. package/docs/getting-started.md +79 -0
  60. package/docs/groups.md +85 -0
  61. package/docs/index.md +5 -0
  62. package/docs/llm-rules.md +81 -0
  63. package/docs/middlewares.md +78 -0
  64. package/docs/overrides/home.html +350 -0
  65. package/docs/plugins.md +59 -0
  66. package/docs/react.md +75 -0
  67. package/docs/realtime.md +43 -0
  68. package/docs/s3-storage.md +40 -0
  69. package/docs/security.md +23 -0
  70. package/docs/stylesheets/extra.css +375 -0
  71. package/docs/svelte.md +71 -0
  72. package/docs/telemetry.md +97 -0
  73. package/docs/upload.md +168 -0
  74. package/docs/validation.md +115 -0
  75. package/docs/vue.md +86 -0
  76. package/docs/webhooks.md +87 -0
  77. package/index.js +11 -0
  78. package/locales/en/admin.json +169 -0
  79. package/mkdocs.yml +82 -0
  80. package/package.json +65 -0
  81. package/playwright.config.js +24 -0
  82. package/public/.gitkeep +0 -0
  83. package/sdk/README.md +284 -0
  84. package/sdk/package.json +39 -0
  85. package/sdk/scripts/build.js +58 -0
  86. package/sdk/src/index.js +368 -0
  87. package/sdk/test/sdk.test.cjs +340 -0
  88. package/sdk/types/index.d.ts +217 -0
  89. package/server/express-server.js +734 -0
  90. package/test/access-policies.test.js +96 -0
  91. package/test/ai.test.js +81 -0
  92. package/test/api-keys.test.js +361 -0
  93. package/test/auth.test.js +122 -0
  94. package/test/browser/admin-ui.spec.js +127 -0
  95. package/test/browser/global-setup.js +71 -0
  96. package/test/browser/global-teardown.js +11 -0
  97. package/test/db.test.js +227 -0
  98. package/test/entity-engine.test.js +193 -0
  99. package/test/error-reporter.test.js +140 -0
  100. package/test/functions-engine.test.js +240 -0
  101. package/test/groups.test.js +212 -0
  102. package/test/hot-reload.test.js +153 -0
  103. package/test/i18n.test.js +173 -0
  104. package/test/middleware.test.js +76 -0
  105. package/test/openapi.test.js +67 -0
  106. package/test/schema-validator.test.js +83 -0
  107. package/test/sdk.test.js +90 -0
  108. package/test/seeder.test.js +279 -0
  109. package/test/settings.test.js +109 -0
  110. package/test/telemetry.test.js +254 -0
  111. package/test/test.js +17 -0
  112. package/test/upload.test.js +265 -0
  113. package/test/validation.test.js +96 -0
  114. package/test/yaml-loader.test.js +93 -0
  115. package/utils/logger.js +24 -0
@@ -0,0 +1,734 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const express = require('express');
8
+ const swaggerUi = require('swagger-ui-express');
9
+ const rateLimit = require('express-rate-limit');
10
+
11
+ const { loadYaml, saveYaml } = require('../core/yaml-loader');
12
+ const { validateSchema } = require('../core/schema-validator');
13
+ const { buildCore } = require('../core/entity-engine');
14
+ const { initDb, findAll, findAllSimple, create: dbCreate } = require('../core/db');
15
+ const { registerApiRoutes } = require('../core/api-generator');
16
+ const { registerAuthRoutes, registerApiKeyRoutes, initApiKeys, verifyToken, omitPassword,
17
+ createApiKey, listAllApiKeys, deleteApiKey } = require('../core/auth');
18
+ const { initRealtime, emit } = require('../core/realtime');
19
+ const { generateOpenApiSpec } = require('../core/openapi');
20
+ const { registerFileRoutes } = require('../core/file-storage');
21
+ const { registerUploadRoutes } = require('../core/upload');
22
+ const { loadPlugins } = require('../core/plugin-loader');
23
+ const { initErrorReporter, getRequestHandler, attachErrorHandler } = require('../core/error-reporter');
24
+ const { getTelemetryConfig, initTelemetry } = require('../core/telemetry');
25
+ const { setupFunctions, cleanup: cleanupFunctions } = require('../core/functions-engine');
26
+ const logger = require('../utils/logger');
27
+
28
+ function limiter(windowMs, max) {
29
+ return rateLimit({ windowMs, max, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, please try again later.' } });
30
+ }
31
+
32
+ /** Locale cache: maps lang code → parsed JSON object (or null on failure). */
33
+ const _localeCache = {};
34
+
35
+ /**
36
+ * Load and cache a locale file from `locales/<lang>/admin.json`.
37
+ * Falls back to English if the requested language is unavailable.
38
+ * Returns null only when even the English fallback is missing.
39
+ *
40
+ * @param {string} lang BCP 47 primary language subtag (e.g. "en", "es").
41
+ * @returns {object|null}
42
+ */
43
+ function loadLocale(lang) {
44
+ if (!/^[a-z]{2,3}$/.test(lang)) lang = 'en';
45
+ if (_localeCache[lang] !== undefined) return _localeCache[lang];
46
+ const filePath = path.join(__dirname, '..', 'locales', lang, 'admin.json');
47
+ if (fs.existsSync(filePath)) {
48
+ try {
49
+ _localeCache[lang] = JSON.parse(fs.readFileSync(filePath, 'utf8'));
50
+ return _localeCache[lang];
51
+ } catch { /* fall through */ }
52
+ }
53
+ _localeCache[lang] = null;
54
+ if (lang !== 'en') return loadLocale('en');
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Extract the primary language subtag from an Accept-Language header value.
60
+ *
61
+ * @param {string} header Value of the Accept-Language HTTP header.
62
+ * @returns {string} Lowercase primary subtag, e.g. "en" or "es".
63
+ */
64
+ function parseLang(header) {
65
+ if (!header) return 'en';
66
+ return (header.split(/[,;]/)[0].trim().split('-')[0] || 'en').toLowerCase();
67
+ }
68
+ const authLimiter = limiter(15 * 60 * 1000, 30);
69
+ const adminRateLimiter = limiter(60 * 1000, 100);
70
+
71
+ /**
72
+ * Build the API rate limiters from core.rateLimits, or the default.
73
+ */
74
+ function buildApiLimiters(core) {
75
+ const configured = core.rateLimits;
76
+ if (configured && configured.length > 0) {
77
+ return configured.map((rl) => limiter(rl.ttl, rl.limit));
78
+ }
79
+ return [limiter(60 * 1000, 200)];
80
+ }
81
+
82
+ /**
83
+ * Build an Express application for the given YAML config.
84
+ *
85
+ * @param {string} yamlPath Path to the chadstart.yaml file.
86
+ * @param {Function|null} reloadFn When provided, the PUT /admin/config route will
87
+ * trigger this callback after saving so the running
88
+ * server picks up the new config without a restart.
89
+ * @returns {{ app: import('express').Application, core: object }}
90
+ */
91
+ async function buildApp(yamlPath, reloadFn) {
92
+ const config = loadYaml(yamlPath);
93
+ validateSchema(config);
94
+ const core = buildCore(config);
95
+ logger.info(`Loading "${core.name}"...`);
96
+
97
+ // Initialize OpenTelemetry (singleton — no-op on hot reload)
98
+ const telConfig = getTelemetryConfig(core.telemetry);
99
+ await initTelemetry(telConfig);
100
+
101
+ const dbPath = core.database
102
+ ? path.resolve(path.dirname(yamlPath), core.database)
103
+ : undefined;
104
+ initDb(core, dbPath);
105
+ initApiKeys();
106
+
107
+ initErrorReporter(core);
108
+
109
+ initErrorReporter(core);
110
+
111
+ const app = express();
112
+ app.use(express.json());
113
+
114
+ // Sentry request handler must be the first middleware (captures req info)
115
+ const sentryRequestHandler = getRequestHandler();
116
+ if (sentryRequestHandler) app.use(sentryRequestHandler);
117
+
118
+ // Public static files
119
+ if (core.public && core.public.folder) {
120
+ const publicDir = path.resolve(core.public.folder);
121
+ const cwd = process.cwd();
122
+ if (!publicDir.startsWith(cwd + path.sep) && publicDir !== cwd) {
123
+ throw new Error(`public.folder "${core.public.folder}" resolves outside the working directory.`);
124
+ }
125
+ logger.info(`Serving public files from: ${publicDir}`);
126
+ fs.mkdirSync(publicDir, { recursive: true });
127
+ app.use(express.static(publicDir));
128
+ }
129
+
130
+ registerFileRoutes(app, core);
131
+ registerUploadRoutes(app, core);
132
+
133
+ app.use('/api/auth', authLimiter);
134
+ registerAuthRoutes(app, core, emit);
135
+ registerApiKeyRoutes(app, core);
136
+
137
+ const apiLimiters = buildApiLimiters(core);
138
+ app.use('/api', ...apiLimiters);
139
+ registerApiRoutes(app, core, emit);
140
+
141
+ // Stop any previous cron tasks / worker processes before registering new ones
142
+ cleanupFunctions();
143
+ setupFunctions(app, core.functions);
144
+
145
+ const openApiSpec = generateOpenApiSpec(core);
146
+ const showApiDocs = process.env.OPEN_API_DOCS !== undefined
147
+ ? process.env.OPEN_API_DOCS === 'true'
148
+ : process.env.NODE_ENV !== 'production';
149
+ if (showApiDocs) {
150
+ app.get('/openapi.json', (_req, res) => res.json(openApiSpec));
151
+ app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiSpec));
152
+ }
153
+
154
+ // Admin UI — serve the SPA, vendor assets, and API endpoints
155
+ const adminHtml = path.join(__dirname, '..', 'admin', 'index.html');
156
+ const loginHtml = path.join(__dirname, '..', 'admin', 'login.html');
157
+ const nodeModulesDir = path.join(__dirname, '..', 'node_modules');
158
+ // Vendor assets served from node_modules (HTMX, Animate.css, Tailwind browser, cronstrue)
159
+ app.get('/admin/vendor/htmx.min.js', adminRateLimiter, (_req, res) => {
160
+ res.sendFile(path.join(nodeModulesDir, 'htmx.org', 'dist', 'htmx.min.js'));
161
+ });
162
+ app.get('/admin/vendor/animate.min.css', adminRateLimiter, (_req, res) => {
163
+ res.sendFile(path.join(nodeModulesDir, 'animate.css', 'animate.min.css'));
164
+ });
165
+ app.get('/admin/vendor/tailwind.js', adminRateLimiter, (_req, res) => {
166
+ res.sendFile(path.join(nodeModulesDir, '@tailwindcss', 'browser', 'dist', 'index.global.js'));
167
+ });
168
+ app.get('/admin/vendor/cronstrue.min.js', adminRateLimiter, (_req, res) => {
169
+ res.sendFile(path.join(nodeModulesDir, 'cronstrue', 'dist', 'cronstrue.min.js'));
170
+ });
171
+ // Public login page
172
+ app.get('/login', adminRateLimiter, (_req, res) => {
173
+ if (fs.existsSync(loginHtml)) {
174
+ res.sendFile(loginHtml);
175
+ } else if (fs.existsSync(adminHtml)) {
176
+ // Fallback: serve the main admin app if login.html doesn't exist yet
177
+ res.sendFile(adminHtml);
178
+ } else {
179
+ res.status(404).send('Login page not found');
180
+ }
181
+ });
182
+ app.get('/admin', adminRateLimiter, (_req, res) => {
183
+ if (fs.existsSync(adminHtml)) {
184
+ res.sendFile(adminHtml);
185
+ } else {
186
+ res.status(404).send('Admin UI not found');
187
+ }
188
+ });
189
+ // Serve locale translation files for the Admin UI i18n
190
+ app.get('/admin/i18n/:lang', adminRateLimiter, (req, res) => {
191
+ // Normalize the route param (simple language code, e.g. "en") to a safe subtag
192
+ const lang = parseLang(req.params.lang);
193
+ const locale = loadLocale(lang);
194
+ if (locale) return res.json(locale);
195
+ res.status(404).json({ error: 'Locale not found' });
196
+ });
197
+ app.get('/admin/schema', (_req, res) => {
198
+ const allEntities = Object.values(core.entities).map((e) => ({
199
+ name: e.name, tableName: e.tableName, slug: e.slug,
200
+ properties: e.properties, belongsTo: e.belongsTo, belongsToMany: e.belongsToMany,
201
+ authenticable: e.authenticable, single: e.single, policies: e.policies,
202
+ }));
203
+ res.json({
204
+ name: core.name,
205
+ entities: allEntities,
206
+ userCollections: allEntities.filter((e) => e.authenticable),
207
+ adminConfig: core.admin,
208
+ });
209
+ });
210
+
211
+ // ── Admin config endpoints ────────────────────────────────────────────
212
+ // GET /admin/config — return the current YAML config as JSON (auth required)
213
+ app.get('/admin/config', adminRateLimiter, (req, res) => {
214
+ const header = req.headers.authorization;
215
+ if (!header || !header.startsWith('Bearer ')) {
216
+ return res.status(401).json({ error: 'Unauthorized' });
217
+ }
218
+ try { verifyToken(header.slice(7)); } catch {
219
+ return res.status(401).json({ error: 'Invalid token' });
220
+ }
221
+ try {
222
+ res.json(loadYaml(yamlPath));
223
+ } catch (e) {
224
+ res.status(500).json({ error: e.message });
225
+ }
226
+ });
227
+
228
+ // PUT /admin/config — receive JSON config, validate, save as YAML, then hot-reload
229
+ app.put('/admin/config', adminRateLimiter, (req, res) => {
230
+ const header = req.headers.authorization;
231
+ if (!header || !header.startsWith('Bearer ')) {
232
+ return res.status(401).json({ error: 'Unauthorized' });
233
+ }
234
+ try { verifyToken(header.slice(7)); } catch {
235
+ return res.status(401).json({ error: 'Invalid token' });
236
+ }
237
+ const newConfig = req.body;
238
+ if (!newConfig || typeof newConfig !== 'object' || Array.isArray(newConfig)) {
239
+ return res.status(400).json({ error: 'Invalid config: expected a JSON object' });
240
+ }
241
+ try {
242
+ validateSchema(newConfig);
243
+ } catch (e) {
244
+ return res.status(400).json({ error: e.message });
245
+ }
246
+ try {
247
+ saveYaml(yamlPath, newConfig);
248
+ if (reloadFn) {
249
+ // Schedule hot reload after the response has been fully flushed
250
+ res.on('finish', () => {
251
+ reloadFn().catch((e) => logger.error('Hot reload failed after config save:', e.message));
252
+ });
253
+ res.json({ success: true, reloading: true, message: 'Config saved. Reloading server…' });
254
+ } else {
255
+ res.json({ success: true, message: 'Config saved. Restart the server to apply changes.' });
256
+ }
257
+ } catch (e) {
258
+ logger.error('Failed to save config:', e.message);
259
+ res.status(500).json({ error: 'Failed to save config' });
260
+ }
261
+ });
262
+
263
+ // ── Admin AI assistant endpoints ──────────────────────────────────────
264
+ // GET /admin/ai/status — tell the UI whether AI chat is available
265
+ app.get('/admin/ai/status', adminRateLimiter, (_req, res) => {
266
+ res.json({ configured: isAiConfigured() || process.env.NODE_ENV !== 'production' });
267
+ });
268
+
269
+ // POST /admin/ai/chat — proxy messages to the configured AI provider (auth required)
270
+ app.post('/admin/ai/chat', adminRateLimiter, async (req, res) => {
271
+ const header = req.headers.authorization;
272
+ if (!header || !header.startsWith('Bearer ')) {
273
+ return res.status(401).json({ error: 'Unauthorized' });
274
+ }
275
+ try { verifyToken(header.slice(7)); } catch {
276
+ return res.status(401).json({ error: 'Invalid token' });
277
+ }
278
+
279
+ const { messages } = req.body || {};
280
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
281
+ return res.status(400).json({ error: 'messages array required' });
282
+ }
283
+
284
+ const provider = getAiProvider();
285
+ if (!provider) {
286
+ if (process.env.NODE_ENV === 'production') {
287
+ return res.status(503).json({ error: 'AI assistant is not configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY, or OPENROUTER_API_KEY.' });
288
+ }
289
+ // Dev/test mode without API key — return a helpful placeholder
290
+ return res.json({ message: 'AI assistant is not configured. Add an API key via environment variables: OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY (Gemini), or OPENROUTER_API_KEY.' });
291
+ }
292
+
293
+ try {
294
+ const message = await callAiProvider(provider, messages);
295
+ res.json({ message });
296
+ } catch (e) {
297
+ logger.error('AI chat error:', e.message);
298
+ res.status(502).json({ error: e.message });
299
+ }
300
+ });
301
+
302
+ // HTMX table partial – returns an HTML fragment used by the Admin UI
303
+ app.get('/admin/partials/table', adminRateLimiter, (req, res) => {
304
+ const header = req.headers.authorization;
305
+ if (!header || !header.startsWith('Bearer ')) {
306
+ return res.status(401).send('<p class="text-red-400 p-4">Unauthorized</p>');
307
+ }
308
+ try { verifyToken(header.slice(7)); } catch {
309
+ return res.status(401).send('<p class="text-red-400 p-4">Invalid token</p>');
310
+ }
311
+ const { type, name } = req.query;
312
+ if (!type || !name) return res.status(400).send('<p class="text-red-400 p-4">Missing type or name</p>');
313
+ const item = type === 'entity'
314
+ ? Object.values(core.entities).find((e) => e.name === name)
315
+ : Object.values(core.authenticableEntities).find((uc) => uc.name === name);
316
+ if (!item) return res.status(404).send('<p class="text-red-400 p-4">Not found</p>');
317
+ const lang = parseLang(req.headers['accept-language']);
318
+ const locale = loadLocale(lang);
319
+ try {
320
+ let rows = findAllSimple(item.tableName);
321
+ if (type === 'collection') rows = rows.map(omitPassword);
322
+ res.send(renderAdminTable(rows, name, type === 'collection', item.name, locale));
323
+ } catch (err) {
324
+ res.status(500).send(`<p class="text-red-400 p-4">Error: ${escAdminHtml(err.message)}</p>`);
325
+ }
326
+ });
327
+ // ── Admin stats endpoint ────────────────────────────────────────────
328
+ app.get('/admin/stats', adminRateLimiter, (req, res) => {
329
+ const header = req.headers.authorization;
330
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
331
+ try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
332
+ try {
333
+ const now = new Date();
334
+ const oneWeekAgo = new Date(now - 7 * 24 * 60 * 60 * 1000);
335
+ const oneMonthAgo = new Date(now - 30 * 24 * 60 * 60 * 1000);
336
+ const allEntities = Object.values(core.entities);
337
+ const entityStats = [];
338
+ const allRecords = [];
339
+ for (const entity of allEntities) {
340
+ try {
341
+ const rows = findAllSimple(entity.tableName);
342
+ const total = rows.length;
343
+ const lastWeek = rows.filter((r) => r.createdAt && new Date(r.createdAt) >= oneWeekAgo).length;
344
+ const lastMonth = rows.filter((r) => r.createdAt && new Date(r.createdAt) >= oneMonthAgo).length;
345
+ entityStats.push({ name: entity.name, tableName: entity.tableName, total, lastWeek, lastMonth });
346
+ const sorted = rows
347
+ .filter((r) => r.createdAt)
348
+ .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
349
+ .slice(0, 5);
350
+ for (const r of sorted) {
351
+ allRecords.push({
352
+ entityName: entity.name,
353
+ id: r.id,
354
+ action: 'created',
355
+ createdAt: r.createdAt,
356
+ label: r.name || r.title || r.email || `${entity.name} #${r.id ? String(r.id).slice(0, 8) : '?'}`,
357
+ });
358
+ }
359
+ } catch { /* skip table errors */ }
360
+ }
361
+ const recentActivity = allRecords
362
+ .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
363
+ .slice(0, 20);
364
+ res.json({ entities: entityStats, recentActivity });
365
+ } catch (err) {
366
+ res.status(500).json({ error: err.message });
367
+ }
368
+ });
369
+
370
+ // ── Admin seed endpoint ─────────────────────────────────────────────
371
+ app.post('/admin/seed', adminRateLimiter, (req, res) => {
372
+ const header = req.headers.authorization;
373
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
374
+ try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
375
+ const { entities: toSeed = [] } = req.body || {};
376
+ const results = [];
377
+ for (const { name, tableName, count = 10 } of toSeed) {
378
+ const entityDef = Object.values(core.entities).find((e) => e.name === name);
379
+ if (!entityDef || !tableName) continue;
380
+ let created = 0;
381
+ for (let i = 1; i <= Math.min(count, 500); i++) {
382
+ const record = {};
383
+ for (const prop of (entityDef.properties || [])) {
384
+ const pName = typeof prop === 'string' ? prop : prop.name;
385
+ const pType = typeof prop === 'string' ? 'string' : (prop.type || 'string');
386
+ if (!pName || pName === 'password') continue;
387
+ switch (pType) {
388
+ case 'email': record[pName] = `user${Date.now()}_${i}@example.com`; break;
389
+ case 'integer': case 'number': case 'float': case 'money':
390
+ record[pName] = Math.floor(Math.random() * 1000); break;
391
+ case 'boolean': record[pName] = Math.random() > 0.5 ? 1 : 0; break;
392
+ case 'date': case 'timestamp': record[pName] = new Date().toISOString(); break;
393
+ default: record[pName] = `Sample ${pName} ${i}`;
394
+ }
395
+ }
396
+ try { const row = dbCreate(tableName, record); emit(`${name}.created`, row); created++; } catch (e) { logger.warn(`Seed: failed to create record for ${name}:`, e.message); }
397
+ }
398
+ results.push({ name, created });
399
+ }
400
+ res.json({ success: true, results });
401
+ });
402
+
403
+ // ── Admin data endpoint (unified, auth-bypassing) ───────────────────
404
+ app.get('/admin/data', adminRateLimiter, (req, res) => {
405
+ const header = req.headers.authorization;
406
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
407
+ try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
408
+ const { type, name, page = 1, perPage = 20, orderBy = 'createdAt', order = 'DESC', search, ...filters } = req.query;
409
+ if (!type || !name) return res.status(400).json({ error: 'Missing type or name' });
410
+ const item = type === 'collection'
411
+ ? (Object.values(core.authenticableEntities || {}).find((e) => e.name === name) || Object.values(core.entities).find((e) => e.name === name))
412
+ : Object.values(core.entities).find((e) => e.name === name);
413
+ if (!item) return res.status(404).json({ error: 'Not found' });
414
+ try {
415
+ const query = { ...filters };
416
+ if (search) {
417
+ const textCols = (item.properties || []).filter((p) => {
418
+ const t = typeof p === 'string' ? 'string' : (p.type || 'string');
419
+ return ['string', 'text', 'richText', 'email'].includes(t);
420
+ });
421
+ if (textCols.length) {
422
+ const colName = typeof textCols[0] === 'string' ? textCols[0] : textCols[0].name;
423
+ query[`${colName}_like`] = `%${search}%`;
424
+ }
425
+ }
426
+ const result = findAll(item.tableName, query, { page, perPage, orderBy, order });
427
+ if (type === 'collection') result.data = result.data.map(omitPassword);
428
+ res.json(result);
429
+ } catch (err) {
430
+ res.status(500).json({ error: err.message });
431
+ }
432
+ });
433
+
434
+ logger.info(' Admin UI available at /admin');
435
+
436
+ // ── Admin API key management ─────────────────────────────────────────────
437
+ function requireAdminToken(req, res) {
438
+ const header = req.headers.authorization;
439
+ if (!header || !header.startsWith('Bearer ')) { res.status(401).json({ error: 'Unauthorized' }); return false; }
440
+ try { verifyToken(header.slice(7)); return true; } catch { res.status(401).json({ error: 'Invalid token' }); return false; }
441
+ }
442
+
443
+ // GET /admin/api-keys — list all API keys
444
+ app.get('/admin/api-keys', adminRateLimiter, (req, res) => {
445
+ if (!requireAdminToken(req, res)) return;
446
+ try { res.json(listAllApiKeys()); } catch (e) { res.status(500).json({ error: e.message }); }
447
+ });
448
+
449
+ // POST /admin/api-keys — create an API key for any user
450
+ app.post('/admin/api-keys', adminRateLimiter, (req, res) => {
451
+ if (!requireAdminToken(req, res)) return;
452
+ const { userId, userEntity, name, permissions, entities: keyEntities, expiresAt } = req.body || {};
453
+ if (!userId || !userEntity) return res.status(400).json({ error: 'userId and userEntity are required' });
454
+ try {
455
+ const result = createApiKey(userId, userEntity, {
456
+ name: name || 'API Key',
457
+ permissions: Array.isArray(permissions) ? permissions : [],
458
+ entities: Array.isArray(keyEntities) ? keyEntities : [],
459
+ expiresAt: expiresAt || null,
460
+ });
461
+ res.status(201).json(result);
462
+ } catch (e) { res.status(500).json({ error: e.message }); }
463
+ });
464
+
465
+ // DELETE /admin/api-keys/:id — delete any API key
466
+ app.delete('/admin/api-keys/:id', adminRateLimiter, (req, res) => {
467
+ if (!requireAdminToken(req, res)) return;
468
+ try { deleteApiKey(req.params.id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
469
+ });
470
+
471
+ // POST /admin/impersonate — generate a short-lived token as a user (for admin preview)
472
+ app.post('/admin/impersonate', adminRateLimiter, (req, res) => {
473
+ const header = req.headers.authorization;
474
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
475
+ let adminPayload;
476
+ try { adminPayload = verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
477
+ const { userId, userEntity } = req.body || {};
478
+ if (!userId || !userEntity) return res.status(400).json({ error: 'userId and userEntity are required' });
479
+ const entity = Object.values(core.authenticableEntities || {}).find((e) => e.name === userEntity);
480
+ if (!entity) return res.status(404).json({ error: 'User collection not found' });
481
+ const { findById } = require('../core/db');
482
+ const user = findById(entity.tableName, userId);
483
+ if (!user) return res.status(404).json({ error: 'User not found' });
484
+ const { signToken } = require('../core/auth');
485
+ const token = signToken(
486
+ { id: userId, entity: userEntity, impersonated: true, impersonatedBy: adminPayload.id },
487
+ '1h'
488
+ );
489
+ const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
490
+ res.json({ token, expiresAt, userId, userEntity, user: omitPassword(user) });
491
+ });
492
+
493
+ await loadPlugins(app, core);
494
+
495
+ app.get('/health', (_req, res) => res.json({ status: 'ok', name: core.name }));
496
+
497
+ // Sentry error handler must be after all routes/middleware but before any
498
+ // other error handlers so it can capture unhandled errors.
499
+ attachErrorHandler(app);
500
+
501
+ return { app, core };
502
+ }
503
+
504
+ async function createServer(yamlPath) {
505
+ const { app, core } = await buildApp(yamlPath, null);
506
+ const server = http.createServer(app);
507
+ initRealtime(server);
508
+ return { app, server, core };
509
+ }
510
+
511
+ async function startServer(yamlPath) {
512
+ // ── Dispatcher pattern ───────────────────────────────────────────────
513
+ // The HTTP server and WebSocket server are created once and never replaced.
514
+ // Hot reload works by rebuilding the Express app and swapping the handler
515
+ // reference that the dispatcher forwards every request to.
516
+ let currentApp = null;
517
+ const dispatcher = (req, res) => currentApp(req, res);
518
+
519
+ const server = http.createServer(dispatcher);
520
+ initRealtime(server);
521
+
522
+ async function reload() {
523
+ logger.info('Reloading config…');
524
+ const result = await buildApp(yamlPath, reload);
525
+ currentApp = result.app;
526
+ logger.info(`Config loaded: "${result.core.name}"`);
527
+ return result;
528
+ }
529
+
530
+ const { core } = await reload();
531
+
532
+ server.listen(core.port, () => {
533
+ logger.info(`Backend is running at http://localhost:${core.port}`);
534
+ logger.info(` API docs: http://localhost:${core.port}/docs`);
535
+ logger.info(` Admin UI: http://localhost:${core.port}/admin`);
536
+ logger.info(` Health: http://localhost:${core.port}/health\n`);
537
+ });
538
+ return { server, core };
539
+ }
540
+
541
+ module.exports = { createServer, startServer, buildApiLimiters, buildApp, getAiProvider, isAiConfigured };
542
+
543
+ // ─── Admin UI helpers ─────────────────────────────────────────────────────────
544
+
545
+ function escAdminHtml(s) {
546
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
547
+ }
548
+
549
+ /**
550
+ * Render an HTML table fragment for the Admin UI HTMX partial.
551
+ * Uses Tailwind utility classes that the Play CDN will process client-side.
552
+ *
553
+ * @param {Array} rows Record rows from the database.
554
+ * @param {string} name Entity/collection display name.
555
+ * @param {boolean} isUserCollection True when the table is for an authenticable entity.
556
+ * @param {string} entityName Entity name used for impersonation (when isUserCollection).
557
+ * @param {object|null} locale Parsed locale JSON (locales/{lang}/admin.json), or null.
558
+ */
559
+ function renderAdminTable(rows, name, isUserCollection, entityName, locale) {
560
+ const esc = escAdminHtml;
561
+ const tbl = (locale && locale.table) || {};
562
+ const tr = (key, fallback) => tbl[key] || fallback;
563
+ if (!rows.length) {
564
+ return `<div class="flex flex-col items-center justify-center py-20 text-center">
565
+ <div class="text-4xl mb-3" aria-hidden="true">&#128237;</div>
566
+ <p class="text-sm" style="color:#888;">${esc(tr('no_records', 'No records yet. Click + New record to create one.'))}</p>
567
+ </div>`;
568
+ }
569
+ const cols = Object.keys(rows[0]);
570
+ const ths = cols.map((c) =>
571
+ `<th class="px-4 py-2.5 text-left text-xs font-medium whitespace-nowrap" style="color:#888;">${esc(c)}</th>`
572
+ ).join('') + `<th class="px-4 py-2.5 text-left text-xs font-medium" style="color:#888;">${esc(tr('actions', 'Actions'))}</th>`;
573
+
574
+ const trs = rows.map((row) => {
575
+ const tds = cols.map((c) =>
576
+ `<td class="px-4 py-2.5 max-w-xs truncate text-sm" style="color:#e1e1e1;" title="${esc(String(row[c] ?? ''))}">${esc(String(row[c] ?? ''))}</td>`
577
+ ).join('');
578
+ const safeJson = JSON.stringify(row)
579
+ .replace(/&/g, '\\u0026').replace(/'/g, '\\u0027').replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
580
+ const safeId = esc(String(row.id ?? ''));
581
+ const safeEntity = esc(String(entityName || ''));
582
+ const impersonateBtn = isUserCollection
583
+ ? `<button class="text-xs border rounded px-2 py-1 hover:opacity-80" style="border-color:rgba(187,134,252,0.4);color:#bb86fc;background:transparent;transition:opacity 150ms ease;"
584
+ onclick="impersonateUser('${safeId}','${safeEntity}')">Impersonate</button>`
585
+ : '';
586
+ const actions = `<td class="px-4 py-2.5"><div class="flex gap-2">
587
+ <button class="text-xs border rounded px-2 py-1 hover:opacity-80" style="border-color:#2a2a2a;color:#e1e1e1;background:transparent;transition:opacity 150ms ease;"
588
+ onclick='openEditModal(${safeJson})'>${esc(tr('edit', 'Edit'))}</button>
589
+ <button class="text-xs border rounded px-2 py-1 hover:opacity-80" style="border-color:rgba(239,68,68,0.4);color:#f87171;background:transparent;transition:opacity 150ms ease;"
590
+ onclick="deleteRecord('${safeId}')">${esc(tr('delete', 'Delete'))}</button>
591
+ ${impersonateBtn}
592
+ </div></td>`;
593
+ return `<tr class="border-b" style="border-color:#2a2a2a;">${tds}${actions}</tr>`;
594
+ }).join('');
595
+
596
+ return `<div class="overflow-x-auto border rounded" style="border-color:#2a2a2a;">
597
+ <table class="w-full text-sm" style="color:#e1e1e1;">
598
+ <thead style="background:#1e1e1e;"><tr class="border-b" style="border-color:#2a2a2a;">${ths}</tr></thead>
599
+ <tbody>${trs}</tbody>
600
+ </table>
601
+ </div>`;
602
+ }
603
+
604
+ // ─── AI provider helpers ──────────────────────────────────────────────────────
605
+
606
+ /**
607
+ * Returns the first configured AI provider, or null if none is set.
608
+ * Priority: openai → anthropic → google → openrouter
609
+ */
610
+ function getAiProvider() {
611
+ if (process.env.OPENAI_API_KEY) return 'openai';
612
+ if (process.env.ANTHROPIC_API_KEY) return 'anthropic';
613
+ if (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) return 'google';
614
+ if (process.env.OPENROUTER_API_KEY) return 'openrouter';
615
+ return null;
616
+ }
617
+
618
+ function isAiConfigured() {
619
+ return getAiProvider() !== null;
620
+ }
621
+
622
+ /**
623
+ * Minimal HTTPS POST helper (avoids adding dependencies for AI provider calls).
624
+ */
625
+ function httpsPost(url, extraHeaders, body) {
626
+ return new Promise((resolve, reject) => {
627
+ const urlObj = new URL(url);
628
+ const data = JSON.stringify(body);
629
+ const options = {
630
+ hostname: urlObj.hostname,
631
+ port: urlObj.port || 443,
632
+ path: urlObj.pathname + urlObj.search,
633
+ method: 'POST',
634
+ headers: {
635
+ 'Content-Type': 'application/json',
636
+ 'Content-Length': Buffer.byteLength(data),
637
+ ...extraHeaders,
638
+ },
639
+ };
640
+ const req = https.request(options, (r) => {
641
+ let raw = '';
642
+ r.on('data', (c) => { raw += c; });
643
+ r.on('end', () => {
644
+ try { resolve({ status: r.statusCode, body: JSON.parse(raw) }); }
645
+ // Non-JSON responses (e.g. HTML error pages) are returned as raw strings
646
+ catch { resolve({ status: r.statusCode, body: raw }); }
647
+ });
648
+ });
649
+ req.on('error', reject);
650
+ req.write(data);
651
+ req.end();
652
+ });
653
+ }
654
+
655
+ const AI_SYSTEM_PROMPT =
656
+ 'You are a helpful AI assistant embedded in the ChadStart Admin UI. ' +
657
+ 'ChadStart is a YAML-first Backend as a Service that lets developers define ' +
658
+ 'their entire backend (entities, auth, API routes, file storage) in a single ' +
659
+ 'YAML file. Help admin users manage their data, understand the API, configure ' +
660
+ 'entities and endpoints, and troubleshoot issues. Be concise and practical.';
661
+
662
+ /**
663
+ * Send a messages array to the configured AI provider and return the reply text.
664
+ * @param {'openai'|'anthropic'|'google'|'openrouter'} provider
665
+ * @param {{ role: string, content: string }[]} messages
666
+ * @returns {Promise<string>}
667
+ */
668
+ /**
669
+ * Extract a human-readable error message from an AI provider API response body.
670
+ * @param {{ error?: { message?: string } } | string} body
671
+ * @param {number} status
672
+ * @returns {string}
673
+ */
674
+ function getApiErrorMessage(body, status) {
675
+ return (body && typeof body === 'object' && body.error && body.error.message) || `AI API error (${status})`;
676
+ }
677
+
678
+ async function callAiProvider(provider, messages) {
679
+ if (provider === 'openai' || provider === 'openrouter') {
680
+ const apiKey = provider === 'openai'
681
+ ? process.env.OPENAI_API_KEY
682
+ : process.env.OPENROUTER_API_KEY;
683
+ const baseUrl = provider === 'openai'
684
+ ? 'https://api.openai.com'
685
+ : 'https://openrouter.ai';
686
+ const model = provider === 'openai' ? 'gpt-4o-mini' : 'openai/gpt-4o-mini';
687
+
688
+ const result = await httpsPost(
689
+ `${baseUrl}/v1/chat/completions`,
690
+ { Authorization: `Bearer ${apiKey}` },
691
+ { model, messages: [{ role: 'system', content: AI_SYSTEM_PROMPT }, ...messages], max_tokens: 1024 }
692
+ );
693
+ if (result.status !== 200) {
694
+ throw new Error(getApiErrorMessage(result.body, result.status));
695
+ }
696
+ return result.body.choices[0].message.content;
697
+ }
698
+
699
+ if (provider === 'anthropic') {
700
+ const result = await httpsPost(
701
+ 'https://api.anthropic.com/v1/messages',
702
+ { 'x-api-key': process.env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' },
703
+ { model: 'claude-3-haiku-20240307', system: AI_SYSTEM_PROMPT, messages, max_tokens: 1024 }
704
+ );
705
+ if (result.status !== 200) {
706
+ throw new Error(getApiErrorMessage(result.body, result.status));
707
+ }
708
+ return result.body.content[0].text;
709
+ }
710
+
711
+ if (provider === 'google') {
712
+ const apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
713
+ // Convert OpenAI-style messages to Google Gemini format
714
+ const googleContents = messages.map((m) => ({
715
+ role: m.role === 'assistant' ? 'model' : 'user',
716
+ parts: [{ text: m.content }],
717
+ }));
718
+ const result = await httpsPost(
719
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${encodeURIComponent(apiKey)}`,
720
+ {},
721
+ {
722
+ system_instruction: { parts: [{ text: AI_SYSTEM_PROMPT }] },
723
+ contents: googleContents,
724
+ generationConfig: { maxOutputTokens: 1024 },
725
+ }
726
+ );
727
+ if (result.status !== 200) {
728
+ throw new Error(getApiErrorMessage(result.body, result.status));
729
+ }
730
+ return result.body.candidates[0].content.parts[0].text;
731
+ }
732
+
733
+ throw new Error('Unsupported AI provider: ' + provider);
734
+ }