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,353 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChadStart Functions Engine
|
|
5
|
+
*
|
|
6
|
+
* Supports multiple runtimes (js, bash, python, go, c++, ruby, php),
|
|
7
|
+
* multiple trigger types (http, event, cron), and multiple JS formats
|
|
8
|
+
* (universal, aws lambda, cloudflare workers).
|
|
9
|
+
* Scripted runtimes (python, ruby, php) use persistent worker processes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const { EventEmitter } = require('events');
|
|
15
|
+
const { execa } = require('execa');
|
|
16
|
+
const cron = require('node-cron');
|
|
17
|
+
const logger = require('../utils/logger');
|
|
18
|
+
|
|
19
|
+
// ── Predefined cron schedule aliases ──────────────────────────────────────────
|
|
20
|
+
const PREDEFINED = {
|
|
21
|
+
'@yearly': '0 0 1 1 *',
|
|
22
|
+
'@annually': '0 0 1 1 *',
|
|
23
|
+
'@monthly': '0 0 1 * *',
|
|
24
|
+
'@weekly': '0 0 * * 0',
|
|
25
|
+
'@daily': '0 0 * * *',
|
|
26
|
+
'@midnight': '0 0 * * *',
|
|
27
|
+
'@hourly': '0 * * * *',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SUPPORTED_RUNTIMES = new Set(['js', 'bash', 'python', 'go', 'c++', 'ruby', 'php']);
|
|
31
|
+
|
|
32
|
+
function resolveSchedule(s) {
|
|
33
|
+
return PREDEFINED[s] || s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Shared event bus ───────────────────────────────────────────────────────────
|
|
37
|
+
const eventBus = new EventEmitter();
|
|
38
|
+
eventBus.setMaxListeners(100);
|
|
39
|
+
|
|
40
|
+
// ── Worker process pool (one process per runtime) ─────────────────────────────
|
|
41
|
+
const _workers = new Map();
|
|
42
|
+
|
|
43
|
+
function getWorkerScript(runtime) {
|
|
44
|
+
const dir = path.join(__dirname, 'workers');
|
|
45
|
+
const map = {
|
|
46
|
+
python: path.join(dir, 'python_worker.py'),
|
|
47
|
+
ruby: path.join(dir, 'ruby_worker.rb'),
|
|
48
|
+
php: path.join(dir, 'php_worker.php'),
|
|
49
|
+
};
|
|
50
|
+
return map[runtime] || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getRuntimeCmd(runtime) {
|
|
54
|
+
const cmds = { python: 'python3', ruby: 'ruby', php: 'php' };
|
|
55
|
+
return cmds[runtime] || runtime;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get (or spawn) a persistent worker process for the given runtime.
|
|
60
|
+
* Returns null for runtimes that don't use persistent workers.
|
|
61
|
+
*/
|
|
62
|
+
function getWorker(runtime) {
|
|
63
|
+
if (_workers.has(runtime)) return _workers.get(runtime);
|
|
64
|
+
|
|
65
|
+
const script = getWorkerScript(runtime);
|
|
66
|
+
if (!script) return null;
|
|
67
|
+
|
|
68
|
+
const proc = execa(getRuntimeCmd(runtime), [script], {
|
|
69
|
+
stdin: 'pipe',
|
|
70
|
+
stdout: 'pipe',
|
|
71
|
+
stderr: 'pipe',
|
|
72
|
+
buffer: false,
|
|
73
|
+
reject: false,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let buf = '';
|
|
77
|
+
const pending = new Map();
|
|
78
|
+
let idCounter = 0;
|
|
79
|
+
|
|
80
|
+
proc.stdout.on('data', (chunk) => {
|
|
81
|
+
buf += chunk.toString();
|
|
82
|
+
let nl;
|
|
83
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
84
|
+
const line = buf.slice(0, nl).trim();
|
|
85
|
+
buf = buf.slice(nl + 1);
|
|
86
|
+
if (!line) continue;
|
|
87
|
+
try {
|
|
88
|
+
const msg = JSON.parse(line);
|
|
89
|
+
const p = pending.get(msg.id);
|
|
90
|
+
if (p) {
|
|
91
|
+
pending.delete(msg.id);
|
|
92
|
+
if (msg.error) p.reject(new Error(msg.error));
|
|
93
|
+
else p.resolve(msg.result);
|
|
94
|
+
}
|
|
95
|
+
} catch { /* ignore parse errors */ }
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
proc.stderr.on('data', (d) => logger.warn(`[${runtime} worker] ${d.toString().trim()}`));
|
|
100
|
+
|
|
101
|
+
proc.on('close', () => {
|
|
102
|
+
_workers.delete(runtime);
|
|
103
|
+
for (const p of pending.values()) p.reject(new Error(`${runtime} worker exited`));
|
|
104
|
+
pending.clear();
|
|
105
|
+
logger.warn(`[functions] ${runtime} worker exited — will restart on next invocation`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const worker = {
|
|
109
|
+
invoke(entry, event, ctx) {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const id = ++idCounter;
|
|
112
|
+
pending.set(id, { resolve, reject });
|
|
113
|
+
proc.stdin.write(JSON.stringify({ id, entry, event, ctx }) + '\n');
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
proc,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
_workers.set(runtime, worker);
|
|
120
|
+
return worker;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── JS function format adapters ───────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Load a JS module with cache-busting for hot reload.
|
|
127
|
+
* Returns a normalised object with `default` and/or `handler` properties.
|
|
128
|
+
*/
|
|
129
|
+
function loadJsModule(entry) {
|
|
130
|
+
delete require.cache[require.resolve(entry)];
|
|
131
|
+
const raw = require(entry);
|
|
132
|
+
const mod = (raw && typeof raw === 'object') ? raw : { default: raw };
|
|
133
|
+
if (!mod.default && typeof raw === 'function') mod.default = raw;
|
|
134
|
+
return mod;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run a JS function — auto-detects format: Universal, AWS Lambda, Cloudflare Workers.
|
|
139
|
+
* Called as fn(event, ctx) for Universal; module.handler(event, {}) for Lambda;
|
|
140
|
+
* module.default.fetch(request) for Cloudflare Workers.
|
|
141
|
+
*/
|
|
142
|
+
async function runJsFunction(entry, event, ctx) {
|
|
143
|
+
const mod = loadJsModule(entry);
|
|
144
|
+
|
|
145
|
+
// Resolve the primary callable; handle module.exports = { default: fn } (CJS wrapped default)
|
|
146
|
+
let defaultExport = mod.default;
|
|
147
|
+
if (defaultExport && typeof defaultExport !== 'function' && typeof defaultExport.default === 'function') {
|
|
148
|
+
defaultExport = defaultExport.default;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 1. Cloudflare / edge style: { default: { fetch(request) } }
|
|
152
|
+
if (defaultExport && typeof defaultExport.fetch === 'function') {
|
|
153
|
+
const req = event.request || new Request(`http://localhost${ctx.path || '/'}`);
|
|
154
|
+
const result = await defaultExport.fetch(req);
|
|
155
|
+
return result.json ? await result.json() : await result.text();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 2. AWS Lambda style: exports.handler (named export, not default)
|
|
159
|
+
if (typeof mod.handler === 'function') {
|
|
160
|
+
const result = await mod.handler(event, {});
|
|
161
|
+
if (result && result.body) { try { return JSON.parse(result.body); } catch { return result.body; } }
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 3. Universal: default export called as fn(event, ctx)
|
|
166
|
+
if (typeof defaultExport === 'function') {
|
|
167
|
+
return defaultExport(event, ctx);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
throw new Error(`No recognised export in ${entry}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── External runtime invocation ───────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async function runExternal(runtime, entry, event, ctx) {
|
|
176
|
+
// Strip the Express request object — it has circular references and cannot be
|
|
177
|
+
// serialised to JSON. The serialised event still contains body, query, params,
|
|
178
|
+
// and headers, which are all a function needs to handle the request.
|
|
179
|
+
const { req: _req, ...safeEvent } = (event || {});
|
|
180
|
+
|
|
181
|
+
const worker = getWorker(runtime);
|
|
182
|
+
if (worker) return worker.invoke(entry, safeEvent, ctx);
|
|
183
|
+
|
|
184
|
+
const input = JSON.stringify({ event: safeEvent, ctx });
|
|
185
|
+
const ts = Date.now();
|
|
186
|
+
const safeEntry = entry.replace(/'/g, "'\\''"); // single-quote escape for shell
|
|
187
|
+
const cmds = {
|
|
188
|
+
bash: ['bash', [entry]],
|
|
189
|
+
go: ['go', ['run', entry]],
|
|
190
|
+
'c++': ['sh', ['-c', `g++ -o /tmp/cs_fn_${ts} '${safeEntry}' && /tmp/cs_fn_${ts}`]],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (!cmds[runtime]) throw new Error(`No command mapping for runtime: "${runtime}"`);
|
|
194
|
+
const [cmd, args] = cmds[runtime];
|
|
195
|
+
try {
|
|
196
|
+
const { stdout } = await execa(cmd, args, { input, reject: false });
|
|
197
|
+
return JSON.parse(stdout);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
throw new Error(`${runtime} runtime error: ${e.message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Unified function runner ────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function resolveFnEntry(fnFile) {
|
|
206
|
+
const entry = path.resolve(process.env.CHADSTART_FUNCTIONS_FOLDER || 'functions', fnFile);
|
|
207
|
+
return fs.existsSync(entry) ? entry : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runFunction(fn, event, ctx) {
|
|
211
|
+
const runtime = fn.runtime || 'js';
|
|
212
|
+
if (!SUPPORTED_RUNTIMES.has(runtime)) throw new Error(`Unsupported runtime: "${runtime}"`);
|
|
213
|
+
const entry = resolveFnEntry(fn.function);
|
|
214
|
+
if (!entry) {
|
|
215
|
+
logger.warn(`Function file not found: ${fn.function}`);
|
|
216
|
+
return { error: `Function not found: ${fn.function}` };
|
|
217
|
+
}
|
|
218
|
+
if (runtime === 'js') return runJsFunction(entry, event, ctx);
|
|
219
|
+
return runExternal(runtime, entry, event, ctx);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Trigger registration ──────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
const _cronTasks = [];
|
|
225
|
+
|
|
226
|
+
/** Register all function triggers on the Express app. */
|
|
227
|
+
function setupFunctions(app, functions) {
|
|
228
|
+
if (!functions || !Object.keys(functions).length) return;
|
|
229
|
+
for (const [name, fn] of Object.entries(functions)) {
|
|
230
|
+
for (const trigger of (fn.triggers || [])) {
|
|
231
|
+
registerTrigger(app, name, fn, trigger);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Stop all active cron tasks and worker processes (call before hot reload). */
|
|
237
|
+
function cleanup() {
|
|
238
|
+
for (const task of _cronTasks) { try { task.stop(); } catch { /* */ } }
|
|
239
|
+
_cronTasks.length = 0;
|
|
240
|
+
for (const [, w] of _workers) { try { w.proc.kill(); } catch { /* */ } }
|
|
241
|
+
_workers.clear();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function registerTrigger(app, name, fn, trigger) {
|
|
245
|
+
switch (trigger.type) {
|
|
246
|
+
case 'http': registerHttp(app, name, fn, trigger); break;
|
|
247
|
+
case 'cron': registerCron(name, fn, trigger); break;
|
|
248
|
+
case 'event': registerEvent(name, fn, trigger); break;
|
|
249
|
+
default: logger.warn(`[functions] Unknown trigger type "${trigger.type}" for "${name}"`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function registerHttp(app, name, fn, trigger) {
|
|
254
|
+
const method = (trigger.method || 'GET').toLowerCase();
|
|
255
|
+
const epPath = trigger.path;
|
|
256
|
+
const middlewares = buildPolicyMiddlewares(trigger.policies);
|
|
257
|
+
|
|
258
|
+
app[method](epPath, ...middlewares, async (req, res) => {
|
|
259
|
+
const entry = resolveFnEntry(fn.function);
|
|
260
|
+
if (!entry) {
|
|
261
|
+
logger.warn(`[functions] File not found for "${name}": ${fn.function}`);
|
|
262
|
+
return res.status(404).json({ error: `Function not found: ${fn.function}` });
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const event = { req, body: req.body, query: req.query, params: req.params, headers: req.headers };
|
|
266
|
+
const ctx = { trigger: 'http', method: req.method, path: epPath, name };
|
|
267
|
+
const result = await runFunction(fn, event, ctx);
|
|
268
|
+
if (!res.headersSent) {
|
|
269
|
+
if (result && typeof result === 'object') return res.json(result);
|
|
270
|
+
res.send(result ?? '');
|
|
271
|
+
}
|
|
272
|
+
} catch (e) {
|
|
273
|
+
logger.error(`[functions] ${name} http error: ${e.message}`);
|
|
274
|
+
if (!res.headersSent) res.status(500).json({ error: e.message });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
logger.info(` Registered function: ${trigger.method || 'GET'} ${epPath} (${name})`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Policy middleware for HTTP triggers ───────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Build Express middleware array from a policies definition.
|
|
285
|
+
* With no policies (or `access: public`) the route is open to everyone.
|
|
286
|
+
* Supports: public | restricted | admin | forbidden
|
|
287
|
+
*/
|
|
288
|
+
function buildPolicyMiddlewares(policies) {
|
|
289
|
+
const { optionalAuth, requireAuth, JWT_SECRET } = require('./auth');
|
|
290
|
+
const jwt = require('jsonwebtoken');
|
|
291
|
+
if (!policies || !policies.length) return [optionalAuth];
|
|
292
|
+
const p = policies[0];
|
|
293
|
+
switch (p.access) {
|
|
294
|
+
case 'public':
|
|
295
|
+
case '🌐':
|
|
296
|
+
return [optionalAuth];
|
|
297
|
+
case 'restricted':
|
|
298
|
+
case '🔒': {
|
|
299
|
+
if (!p.allow) return [requireAuth()];
|
|
300
|
+
const allowed = Array.isArray(p.allow) ? p.allow : [p.allow];
|
|
301
|
+
return [(req, res, next) => {
|
|
302
|
+
const h = req.headers.authorization;
|
|
303
|
+
if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: 'Authorization required' });
|
|
304
|
+
try {
|
|
305
|
+
req.user = jwt.verify(h.slice(7), JWT_SECRET);
|
|
306
|
+
if (!allowed.includes(req.user.entity)) return res.status(403).json({ error: 'Access denied' });
|
|
307
|
+
next();
|
|
308
|
+
} catch { res.status(401).json({ error: 'Invalid or expired token' }); }
|
|
309
|
+
}];
|
|
310
|
+
}
|
|
311
|
+
case 'admin':
|
|
312
|
+
case '👨🏻💻':
|
|
313
|
+
return [requireAuth()];
|
|
314
|
+
case 'forbidden':
|
|
315
|
+
case '🚫':
|
|
316
|
+
return [(_r, res) => res.status(403).json({ error: 'Access forbidden' })];
|
|
317
|
+
default:
|
|
318
|
+
return [optionalAuth];
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function registerCron(name, fn, trigger) {
|
|
323
|
+
const schedule = resolveSchedule(trigger.schedule);
|
|
324
|
+
if (!cron.validate(schedule)) {
|
|
325
|
+
logger.warn(`[functions] Invalid cron schedule "${trigger.schedule}" for "${name}"`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const task = cron.schedule(schedule, async () => {
|
|
329
|
+
try {
|
|
330
|
+
await runFunction(fn, {}, { trigger: 'cron', schedule: trigger.schedule, name });
|
|
331
|
+
} catch (e) {
|
|
332
|
+
logger.error(`[functions] ${name} cron error: ${e.message}`);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
_cronTasks.push(task);
|
|
336
|
+
logger.info(` Registered cron: "${trigger.schedule}" → ${name}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function registerEvent(name, fn, trigger) {
|
|
340
|
+
const eventName = trigger.name || trigger.event;
|
|
341
|
+
if (!eventName) { logger.warn(`[functions] Event trigger for "${name}" has no name`); return; }
|
|
342
|
+
eventBus.on(eventName, async (payload) => {
|
|
343
|
+
try {
|
|
344
|
+
await runFunction(fn, payload || {}, { trigger: 'event', event: eventName, name });
|
|
345
|
+
} catch (e) {
|
|
346
|
+
logger.error(`[functions] ${name} event error: ${e.message}`);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
logger.info(` Registered event: "${eventName}" → ${name}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
module.exports = { setupFunctions, cleanup, eventBus, resolveSchedule };
|
|
353
|
+
|
package/core/openapi.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const OPENAPI_TYPE = {
|
|
4
|
+
text: 'string', string: 'string', richText: 'string',
|
|
5
|
+
integer: 'integer', int: 'integer',
|
|
6
|
+
number: 'number', float: 'number', real: 'number', money: 'number',
|
|
7
|
+
boolean: 'boolean', bool: 'boolean',
|
|
8
|
+
date: 'string', timestamp: 'string', email: 'string', link: 'string',
|
|
9
|
+
password: 'string', choice: 'string', location: 'string',
|
|
10
|
+
file: 'string', image: 'string', group: 'string', json: 'string',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function generateOpenApiSpec(core) {
|
|
14
|
+
const spec = {
|
|
15
|
+
openapi: '3.0.0',
|
|
16
|
+
info: { title: core.name, version: '1.0.0', description: `API generated by ChadStart for "${core.name}"` },
|
|
17
|
+
paths: {},
|
|
18
|
+
components: {
|
|
19
|
+
schemas: {},
|
|
20
|
+
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Auth endpoints for authenticable entities
|
|
25
|
+
for (const e of Object.values(core.authenticableEntities || {})) {
|
|
26
|
+
const slug = e.slug;
|
|
27
|
+
spec.components.schemas[e.name] = authSchema(e);
|
|
28
|
+
spec.components.schemas[`${e.name}Input`] = authInputSchema(e);
|
|
29
|
+
spec.components.schemas[`${e.name}LoginInput`] = loginSchema();
|
|
30
|
+
spec.components.schemas[`${e.name}AuthResponse`] = { type: 'object', properties: { token: { type: 'string' }, user: { $ref: `#/components/schemas/${e.name}` } } };
|
|
31
|
+
|
|
32
|
+
spec.paths[`/api/auth/${slug}/signup`] = { post: { tags: [`Auth – ${e.name}`], summary: `Sign up as ${e.name}`, requestBody: jsonBody(`${e.name}Input`), responses: { 201: jsonResp(e.name + 'AuthResponse'), 400: desc('Validation error'), 409: desc('Email already registered') } } };
|
|
33
|
+
spec.paths[`/api/auth/${slug}/login`] = { post: { tags: [`Auth – ${e.name}`], summary: `Login as ${e.name}`, requestBody: jsonBody(`${e.name}LoginInput`), responses: { 200: jsonResp(e.name + 'AuthResponse'), 401: desc('Invalid credentials') } } };
|
|
34
|
+
spec.paths[`/api/auth/${slug}/me`] = { get: { tags: [`Auth – ${e.name}`], summary: `Get current ${e.name}`, security: [{ bearerAuth: [] }], responses: { 200: jsonResp(e.name), 401: desc('Unauthorized') } } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Entity CRUD endpoints
|
|
38
|
+
for (const e of Object.values(core.entities)) {
|
|
39
|
+
const tag = e.name;
|
|
40
|
+
const sec = (rule) => hasSecurity(e, rule) ? { security: [{ bearerAuth: [] }] } : {};
|
|
41
|
+
|
|
42
|
+
if (!e.authenticable) {
|
|
43
|
+
spec.components.schemas[e.name] = entitySchema(e, core.entities);
|
|
44
|
+
spec.components.schemas[`${e.name}Input`] = entityInputSchema(e, core.entities);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (e.single) {
|
|
48
|
+
const base = `/api/singles/${e.slug}`;
|
|
49
|
+
spec.paths[base] = {
|
|
50
|
+
get: { tags: [tag], summary: `Get ${e.name}`, ...sec('read'), responses: { 200: jsonResp(e.name), 404: desc('Not Found') } },
|
|
51
|
+
put: { tags: [tag], summary: `Replace ${e.name}`, ...sec('update'), requestBody: jsonBody(`${e.name}Input`), responses: { 200: jsonResp(e.name), 404: desc('Not Found') } },
|
|
52
|
+
patch: { tags: [tag], summary: `Update ${e.name}`, ...sec('update'), requestBody: jsonBody(`${e.name}Input`), responses: { 200: jsonResp(e.name), 404: desc('Not Found') } },
|
|
53
|
+
};
|
|
54
|
+
} else {
|
|
55
|
+
const base = `/api/collections/${e.slug}`;
|
|
56
|
+
const paginatedResp = {
|
|
57
|
+
description: 'OK',
|
|
58
|
+
content: {
|
|
59
|
+
'application/json': {
|
|
60
|
+
schema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
data: { type: 'array', items: { $ref: `#/components/schemas/${e.name}` } },
|
|
64
|
+
currentPage: { type: 'integer' },
|
|
65
|
+
lastPage: { type: 'integer' },
|
|
66
|
+
from: { type: 'integer' },
|
|
67
|
+
to: { type: 'integer' },
|
|
68
|
+
total: { type: 'integer' },
|
|
69
|
+
perPage: { type: 'integer' },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
spec.paths[base] = {
|
|
77
|
+
get: {
|
|
78
|
+
tags: [tag],
|
|
79
|
+
summary: `List all ${e.name}s`,
|
|
80
|
+
...sec('read'),
|
|
81
|
+
parameters: [
|
|
82
|
+
...e.properties.filter((p) => !p.hidden).map((p) => ({ name: p.name, in: 'query', required: false, schema: { type: 'string' } })),
|
|
83
|
+
{ name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
|
|
84
|
+
{ name: 'perPage', in: 'query', schema: { type: 'integer', default: 10 } },
|
|
85
|
+
{ name: 'orderBy', in: 'query', schema: { type: 'string' } },
|
|
86
|
+
{ name: 'order', in: 'query', schema: { type: 'string', enum: ['ASC', 'DESC'] } },
|
|
87
|
+
{ name: 'relations', in: 'query', schema: { type: 'string' }, description: 'Comma-separated relation names to load' },
|
|
88
|
+
],
|
|
89
|
+
responses: { 200: paginatedResp },
|
|
90
|
+
},
|
|
91
|
+
post: { tags: [tag], summary: `Create a ${e.name}`, ...sec('create'), requestBody: jsonBody(`${e.name}Input`), responses: { 201: jsonResp(e.name), 400: desc('Bad Request') } },
|
|
92
|
+
};
|
|
93
|
+
spec.paths[`${base}/{id}`] = {
|
|
94
|
+
get: { tags: [tag], summary: `Get ${e.name} by id`, ...sec('read'), parameters: [idParam()], responses: { 200: jsonResp(e.name), 404: desc('Not Found') } },
|
|
95
|
+
put: { tags: [tag], summary: `Replace ${e.name}`, ...sec('update'), parameters: [idParam()], requestBody: jsonBody(`${e.name}Input`), responses: { 200: jsonResp(e.name), 404: desc('Not Found') } },
|
|
96
|
+
patch: { tags: [tag], summary: `Update ${e.name}`, ...sec('update'), parameters: [idParam()], requestBody: jsonBody(`${e.name}Input`), responses: { 200: jsonResp(e.name), 404: desc('Not Found') } },
|
|
97
|
+
delete: { tags: [tag], summary: `Delete ${e.name}`, ...sec('delete'), parameters: [idParam()], responses: { 200: desc('Deleted'), 404: desc('Not Found') } },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Custom functions
|
|
103
|
+
for (const [name, ep] of Object.entries(core.functions || {})) {
|
|
104
|
+
for (const trigger of (ep.triggers || []).filter((t) => t.type === 'http')) {
|
|
105
|
+
const method = (trigger.method || 'GET').toLowerCase();
|
|
106
|
+
spec.paths[trigger.path] = spec.paths[trigger.path] || {};
|
|
107
|
+
spec.paths[trigger.path][method] = { tags: ['Functions'], summary: ep.description || name, responses: { 200: desc('OK') } };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// File storage
|
|
112
|
+
for (const [bucket] of Object.entries(core.files || {})) {
|
|
113
|
+
spec.paths[`/files/${bucket}`] = { post: { tags: ['Files'], summary: `Upload to ${bucket}`, requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } } } }, responses: { 200: desc('Uploaded') } } };
|
|
114
|
+
spec.paths[`/files/${bucket}/{file}`] = { get: { tags: ['Files'], summary: `Download from ${bucket}`, parameters: [{ name: 'file', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: desc('File content'), 404: desc('Not Found') } } };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return spec;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Schema helpers ─────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
function entitySchema(e, all) {
|
|
123
|
+
const props = { id: { type: 'string', format: 'uuid', readOnly: true }, createdAt: { type: 'string', format: 'date-time', readOnly: true }, updatedAt: { type: 'string', format: 'date-time', readOnly: true } };
|
|
124
|
+
for (const p of e.properties) {
|
|
125
|
+
if (!p.hidden) props[p.name] = { type: OPENAPI_TYPE[p.type] || 'string' };
|
|
126
|
+
}
|
|
127
|
+
for (const r of e.belongsTo || []) {
|
|
128
|
+
const ref = all[typeof r === 'string' ? r : (r.entity || r.name)];
|
|
129
|
+
if (ref) props[`${ref.tableName}_id`] = { type: 'string', format: 'uuid' };
|
|
130
|
+
}
|
|
131
|
+
return { type: 'object', properties: props };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function entityInputSchema(e, all) {
|
|
135
|
+
const s = entitySchema(e, all);
|
|
136
|
+
const { id, createdAt, updatedAt, ...rest } = s.properties;
|
|
137
|
+
return { type: 'object', properties: rest };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function authSchema(e) {
|
|
141
|
+
const props = { id: { type: 'string', format: 'uuid', readOnly: true }, email: { type: 'string', format: 'email' }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' } };
|
|
142
|
+
for (const p of e.properties) {
|
|
143
|
+
if (!p.hidden) props[p.name] = { type: OPENAPI_TYPE[p.type] || 'string' };
|
|
144
|
+
}
|
|
145
|
+
return { type: 'object', properties: props };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function authInputSchema(e) {
|
|
149
|
+
const props = { email: { type: 'string', format: 'email' }, password: { type: 'string', format: 'password' } };
|
|
150
|
+
for (const p of e.properties) {
|
|
151
|
+
if (!p.hidden) props[p.name] = { type: OPENAPI_TYPE[p.type] || 'string' };
|
|
152
|
+
}
|
|
153
|
+
return { type: 'object', required: ['email', 'password'], properties: props };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function loginSchema() {
|
|
157
|
+
return { type: 'object', required: ['email', 'password'], properties: { email: { type: 'string', format: 'email' }, password: { type: 'string', format: 'password' } } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hasSecurity(e, rule) {
|
|
161
|
+
const list = (e.policies || {})[rule];
|
|
162
|
+
if (list && list.length) return list[0].access !== 'public';
|
|
163
|
+
return true; // default: requires auth
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function jsonBody(schema) { return { required: true, content: { 'application/json': { schema: { $ref: `#/components/schemas/${schema}` } } } }; }
|
|
167
|
+
function jsonResp(schema) { return { description: 'OK', content: { 'application/json': { schema: { $ref: `#/components/schemas/${schema}` } } } }; }
|
|
168
|
+
function desc(d) { return { description: d }; }
|
|
169
|
+
function idParam() { return { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }; }
|
|
170
|
+
|
|
171
|
+
module.exports = { generateOpenApiSpec };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const logger = require('../utils/logger');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Load and register plugins defined in core.plugins.
|
|
9
|
+
*
|
|
10
|
+
* Supported sources:
|
|
11
|
+
* - { path: './my-plugin' } — local path (relative to cwd)
|
|
12
|
+
* - { repo: 'https://github.com/...' } — clones the repo and loads index.js
|
|
13
|
+
*
|
|
14
|
+
* Plugin module interface:
|
|
15
|
+
* module.exports = {
|
|
16
|
+
* name: 'plugin-name',
|
|
17
|
+
* register(app, core) { ... }
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
async function loadPlugins(app, core) {
|
|
21
|
+
for (const pluginDef of core.plugins) {
|
|
22
|
+
if (pluginDef.repo) {
|
|
23
|
+
logger.warn(
|
|
24
|
+
` ⚠️ Loading remote plugin from "${pluginDef.repo}". ` +
|
|
25
|
+
'Remote plugins execute arbitrary code. Only load plugins from trusted sources.'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const plugin = await resolvePlugin(pluginDef);
|
|
30
|
+
if (typeof plugin.register === 'function') {
|
|
31
|
+
await plugin.register(app, core);
|
|
32
|
+
logger.info(` Plugin "${plugin.name || 'unnamed'}" loaded`);
|
|
33
|
+
} else {
|
|
34
|
+
logger.warn(` Plugin "${pluginDef.repo || pluginDef.path}" has no register() function`);
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
logger.error(` Failed to load plugin "${pluginDef.repo || pluginDef.path}": ${err.message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function resolvePlugin(pluginDef) {
|
|
43
|
+
if (pluginDef.path) {
|
|
44
|
+
const resolved = path.resolve(pluginDef.path);
|
|
45
|
+
return require(resolved);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (pluginDef.repo) {
|
|
49
|
+
return loadRepoPlugin(pluginDef.repo);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error('Plugin must have "path" or "repo" field');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Allow only HTTPS and SSH git URLs to prevent command injection
|
|
56
|
+
const SAFE_REPO_RE = /^(https:\/\/[a-zA-Z0-9._\-/:%@]+|git@[a-zA-Z0-9._-]+:[a-zA-Z0-9._\-/]+)(\.git)?$/;
|
|
57
|
+
|
|
58
|
+
async function loadRepoPlugin(repoUrl) {
|
|
59
|
+
if (!SAFE_REPO_RE.test(repoUrl)) {
|
|
60
|
+
throw new Error(`Plugin repo URL is not a valid git URL: ${repoUrl}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Derive a directory name from the repo URL
|
|
64
|
+
const repoName = repoUrl.replace(/\.git$/, '').split('/').pop();
|
|
65
|
+
if (!repoName || repoName === '.' || repoName === '..') {
|
|
66
|
+
throw new Error(`Cannot derive a safe directory name from repo URL: ${repoUrl}`);
|
|
67
|
+
}
|
|
68
|
+
const pluginDir = path.resolve(`.chadstart-plugins/${repoName}`);
|
|
69
|
+
|
|
70
|
+
if (!fs.existsSync(pluginDir)) {
|
|
71
|
+
logger.info(` Cloning plugin from ${repoUrl}...`);
|
|
72
|
+
const { execFileSync } = require('child_process');
|
|
73
|
+
fs.mkdirSync(path.dirname(pluginDir), { recursive: true });
|
|
74
|
+
// Use execFileSync with an argument array to avoid shell injection
|
|
75
|
+
execFileSync('git', ['clone', '--depth', '1', repoUrl, pluginDir], { stdio: 'pipe' });
|
|
76
|
+
|
|
77
|
+
// Install plugin dependencies if package.json exists.
|
|
78
|
+
// NOTE: --ignore-scripts prevents execution of preinstall/postinstall scripts,
|
|
79
|
+
// but plugins may still contain arbitrary code that runs at require() time.
|
|
80
|
+
// Only load plugins from sources you trust.
|
|
81
|
+
if (fs.existsSync(path.join(pluginDir, 'package.json'))) {
|
|
82
|
+
execFileSync('npm', ['install', '--omit=dev', '--ignore-scripts'], {
|
|
83
|
+
cwd: pluginDir,
|
|
84
|
+
stdio: 'pipe',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return require(pluginDir);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { loadPlugins };
|