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.
- package/.dockerignore +10 -0
- package/.env.example +46 -0
- package/.github/workflows/browser-test.yml +34 -0
- package/.github/workflows/docker-publish.yml +54 -0
- package/.github/workflows/docs.yml +31 -0
- package/.github/workflows/npm-chadstart.yml +27 -0
- package/.github/workflows/npm-sdk.yml +38 -0
- package/.github/workflows/test.yml +85 -0
- package/.weblate +9 -0
- package/Dockerfile +23 -0
- package/README.md +348 -0
- package/admin/index.html +2802 -0
- package/admin/login.html +207 -0
- package/chadstart.example.yml +416 -0
- package/chadstart.schema.json +367 -0
- package/chadstart.yaml +53 -0
- package/cli/cli.js +295 -0
- package/core/api-generator.js +606 -0
- package/core/auth.js +298 -0
- package/core/db.js +384 -0
- package/core/entity-engine.js +166 -0
- package/core/error-reporter.js +132 -0
- package/core/file-storage.js +97 -0
- package/core/functions-engine.js +353 -0
- package/core/openapi.js +171 -0
- package/core/plugin-loader.js +92 -0
- package/core/realtime.js +93 -0
- package/core/schema-validator.js +50 -0
- package/core/seeder.js +231 -0
- package/core/telemetry.js +119 -0
- package/core/upload.js +372 -0
- package/core/workers/php_worker.php +19 -0
- package/core/workers/python_worker.py +33 -0
- package/core/workers/ruby_worker.rb +21 -0
- package/core/yaml-loader.js +64 -0
- package/demo/chadstart.yaml +178 -0
- package/demo/docker-compose.yml +31 -0
- package/demo/functions/greet.go +39 -0
- package/demo/functions/hello.cpp +18 -0
- package/demo/functions/hello.py +13 -0
- package/demo/functions/hello.rb +10 -0
- package/demo/functions/onTodoCreated.js +13 -0
- package/demo/functions/ping.sh +13 -0
- package/demo/functions/stats.js +22 -0
- package/demo/public/index.html +522 -0
- package/docker-compose.yml +17 -0
- package/docs/access-policies.md +155 -0
- package/docs/admin-ui.md +29 -0
- package/docs/angular.md +69 -0
- package/docs/astro.md +71 -0
- package/docs/auth.md +160 -0
- package/docs/cli.md +56 -0
- package/docs/config.md +127 -0
- package/docs/crud.md +627 -0
- package/docs/deploy.md +113 -0
- package/docs/docker.md +59 -0
- package/docs/entities.md +385 -0
- package/docs/functions.md +196 -0
- package/docs/getting-started.md +79 -0
- package/docs/groups.md +85 -0
- package/docs/index.md +5 -0
- package/docs/llm-rules.md +81 -0
- package/docs/middlewares.md +78 -0
- package/docs/overrides/home.html +350 -0
- package/docs/plugins.md +59 -0
- package/docs/react.md +75 -0
- package/docs/realtime.md +43 -0
- package/docs/s3-storage.md +40 -0
- package/docs/security.md +23 -0
- package/docs/stylesheets/extra.css +375 -0
- package/docs/svelte.md +71 -0
- package/docs/telemetry.md +97 -0
- package/docs/upload.md +168 -0
- package/docs/validation.md +115 -0
- package/docs/vue.md +86 -0
- package/docs/webhooks.md +87 -0
- package/index.js +11 -0
- package/locales/en/admin.json +169 -0
- package/mkdocs.yml +82 -0
- package/package.json +65 -0
- package/playwright.config.js +24 -0
- package/public/.gitkeep +0 -0
- package/sdk/README.md +284 -0
- package/sdk/package.json +39 -0
- package/sdk/scripts/build.js +58 -0
- package/sdk/src/index.js +368 -0
- package/sdk/test/sdk.test.cjs +340 -0
- package/sdk/types/index.d.ts +217 -0
- package/server/express-server.js +734 -0
- package/test/access-policies.test.js +96 -0
- package/test/ai.test.js +81 -0
- package/test/api-keys.test.js +361 -0
- package/test/auth.test.js +122 -0
- package/test/browser/admin-ui.spec.js +127 -0
- package/test/browser/global-setup.js +71 -0
- package/test/browser/global-teardown.js +11 -0
- package/test/db.test.js +227 -0
- package/test/entity-engine.test.js +193 -0
- package/test/error-reporter.test.js +140 -0
- package/test/functions-engine.test.js +240 -0
- package/test/groups.test.js +212 -0
- package/test/hot-reload.test.js +153 -0
- package/test/i18n.test.js +173 -0
- package/test/middleware.test.js +76 -0
- package/test/openapi.test.js +67 -0
- package/test/schema-validator.test.js +83 -0
- package/test/sdk.test.js +90 -0
- package/test/seeder.test.js +279 -0
- package/test/settings.test.js +109 -0
- package/test/telemetry.test.js +254 -0
- package/test/test.js +17 -0
- package/test/upload.test.js +265 -0
- package/test/validation.test.js +96 -0
- package/test/yaml-loader.test.js +93 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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">📭</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
|
+
}
|