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,606 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const jwt = require('jsonwebtoken');
7
+ const db = require('./db');
8
+ const { toSnakeCase } = require('./entity-engine');
9
+ const { requireAuth, optionalAuth, omitPassword, JWT_SECRET, resolveAuthHeader } = require('./auth');
10
+ const logger = require('../utils/logger');
11
+
12
+ /**
13
+ * Create a backend SDK for use in middleware and custom endpoint functions.
14
+ * Provides a `from(slug)` interface for CRUD and a `single(slug)` interface
15
+ * for single-record entities — the same API as the front-end JS SDK.
16
+ */
17
+ function createBackendSdk(core) {
18
+ return {
19
+ from(slug) {
20
+ const entity = Object.values(core.entities).find(
21
+ (e) => e.slug === slug || e.slug + 's' === slug || slug === e.tableName
22
+ );
23
+ if (!entity) throw new Error(`Entity not found for slug: ${slug}`);
24
+ const table = entity.tableName;
25
+ return {
26
+ find(opts) { return db.findAll(table, {}, opts || {}); },
27
+ findOneById(id) { return db.findById(table, id); },
28
+ create(data) { return db.create(table, data); },
29
+ update(id, data) { return db.update(table, id, data); },
30
+ patch(id, data) { return db.update(table, id, data); },
31
+ delete(id) { return db.remove(table, id); },
32
+ };
33
+ },
34
+ single(slug) {
35
+ const entity = Object.values(core.entities).find(
36
+ (e) => (e.slug === slug || e.tableName === slug) && e.single
37
+ );
38
+ if (!entity) throw new Error(`Single entity not found for slug: ${slug}`);
39
+ const table = entity.tableName;
40
+ return {
41
+ get() {
42
+ const rows = db.findAllSimple(table);
43
+ return rows[0] || null;
44
+ },
45
+ update(data) {
46
+ const rows = db.findAllSimple(table);
47
+ if (!rows[0]) return null;
48
+ return db.update(table, rows[0].id, data);
49
+ },
50
+ patch(data) {
51
+ const rows = db.findAllSimple(table);
52
+ if (!rows[0]) return null;
53
+ return db.update(table, rows[0].id, data);
54
+ },
55
+ };
56
+ },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Register CRUD REST routes for all entities.
62
+ *
63
+ * Collections: /api/collections/:slug
64
+ * Singles: /api/singles/:slug
65
+ */
66
+ function registerApiRoutes(app, core, emit) {
67
+ const router = express.Router();
68
+ const sdk = createBackendSdk(core);
69
+
70
+ for (const entity of Object.values(core.entities)) {
71
+ const slug = entity.slug;
72
+ const table = entity.tableName;
73
+ const clean = entity.authenticable ? omitPassword : (r) => r;
74
+ const hide = (row) => hideHiddenProps(deserializeGroupProps(clean(row), entity), entity);
75
+
76
+ if (entity.single) {
77
+ const base = `/singles/${slug}`;
78
+
79
+ const mw = {
80
+ read: policyMiddleware('read', entity, core),
81
+ update: policyMiddleware('update', entity, core),
82
+ };
83
+
84
+ // GET single
85
+ router.get(base, mw.read, (_req, res) => {
86
+ try {
87
+ const rows = db.findAllSimple(table);
88
+ const row = rows[0];
89
+ if (!row) return res.status(404).json({ error: 'Not found' });
90
+ res.json(hide(row));
91
+ } catch (e) { res.status(500).json({ error: e.message }); }
92
+ });
93
+
94
+ // PUT single (full replace)
95
+ router.put(base, mw.update, async (req, res) => {
96
+ try {
97
+ const rows = db.findAllSimple(table);
98
+ const row = rows[0];
99
+ if (!row) return res.status(404).json({ error: 'Not found' });
100
+ if (!await runMiddlewares('beforeUpdate', entity, req, res, sdk)) return;
101
+ const v = validateBody(req.body, entity, core.groups);
102
+ if (v.errors) return res.status(400).json(v.errors);
103
+ fireWebhooks(entity, 'beforeUpdate', req.body);
104
+ const sanitized = sanitizeBody(req.body, entity, true);
105
+ const updated = db.update(table, row.id, sanitized);
106
+ fireWebhooks(entity, 'afterUpdate', updated);
107
+ await runMiddlewares('afterUpdate', entity, req, res, sdk);
108
+ emit(`${entity.name}.updated`, hide(updated));
109
+ res.json(hide(updated));
110
+ } catch (e) { res.status(400).json({ error: e.message }); }
111
+ });
112
+
113
+ // PATCH single (partial)
114
+ router.patch(base, mw.update, async (req, res) => {
115
+ try {
116
+ const rows = db.findAllSimple(table);
117
+ const row = rows[0];
118
+ if (!row) return res.status(404).json({ error: 'Not found' });
119
+ if (!await runMiddlewares('beforeUpdate', entity, req, res, sdk)) return;
120
+ const v = validateBody(req.body, entity, core.groups, { partial: true });
121
+ if (v.errors) return res.status(400).json(v.errors);
122
+ fireWebhooks(entity, 'beforeUpdate', req.body);
123
+ const updated = db.update(table, row.id, sanitizeBody(req.body, entity));
124
+ fireWebhooks(entity, 'afterUpdate', updated);
125
+ await runMiddlewares('afterUpdate', entity, req, res, sdk);
126
+ emit(`${entity.name}.updated`, hide(updated));
127
+ res.json(hide(updated));
128
+ } catch (e) { res.status(400).json({ error: e.message }); }
129
+ });
130
+
131
+ logger.info(` Registered single routes at /api/singles/${slug}`);
132
+ } else {
133
+ const base = `/collections/${slug}`;
134
+
135
+ const mw = {
136
+ create: policyMiddleware('create', entity, core),
137
+ read: policyMiddleware('read', entity, core),
138
+ update: policyMiddleware('update', entity, core),
139
+ delete: policyMiddleware('delete', entity, core),
140
+ };
141
+
142
+ // GET list (paginated)
143
+ router.get(base, mw.read, (req, res) => {
144
+ try {
145
+ // Ownership filter: condition: self forces a FK filter on the current user
146
+ const query = req._selfFilter
147
+ ? { ...req.query, [req._selfFilter.fk]: req._selfFilter.userId }
148
+ : req.query;
149
+ const result = db.findAll(table, query, {
150
+ page: req.query.page,
151
+ perPage: req.query.perPage,
152
+ orderBy: req.query.orderBy,
153
+ order: req.query.order,
154
+ });
155
+ const relations = req.query.relations;
156
+ result.data = result.data.map((row) => {
157
+ if (relations) db.loadRelations(row, entity, relations);
158
+ return hide(row);
159
+ });
160
+ res.json(result);
161
+ } catch (e) { res.status(500).json({ error: e.message }); }
162
+ });
163
+
164
+ // GET single by id
165
+ router.get(`${base}/:id`, mw.read, (req, res) => {
166
+ try {
167
+ const row = db.findById(table, req.params.id);
168
+ if (!row) return res.status(404).json({ error: 'Not found' });
169
+ // Ownership check for read with condition: self
170
+ if (req._selfFilter && row[req._selfFilter.fk] !== req._selfFilter.userId) {
171
+ return res.status(403).json({ error: 'Access denied' });
172
+ }
173
+ if (req.query.relations) db.loadRelations(row, entity, req.query.relations);
174
+ res.json(hide(row));
175
+ } catch (e) { res.status(500).json({ error: e.message }); }
176
+ });
177
+
178
+ // POST create
179
+ router.post(base, mw.create, async (req, res) => {
180
+ try {
181
+ if (!await runMiddlewares('beforeCreate', entity, req, res, sdk)) return;
182
+ const body = applyDefaults(req.body, entity);
183
+ const v = validateBody(body, entity, core.groups);
184
+ if (v.errors) return res.status(400).json(v.errors);
185
+ fireWebhooks(entity, 'beforeCreate', body);
186
+ const row = db.create(table, sanitizeBody(body, entity));
187
+ db.saveBelongsToMany(entity, row.id, req.body);
188
+ fireWebhooks(entity, 'afterCreate', row);
189
+ await runMiddlewares('afterCreate', entity, req, res, sdk);
190
+ emit(`${entity.name}.created`, hide(row));
191
+ res.status(201).json(hide(row));
192
+ } catch (e) { res.status(400).json({ error: e.message }); }
193
+ });
194
+
195
+ // PUT full replace
196
+ router.put(`${base}/:id`, mw.update, async (req, res) => {
197
+ try {
198
+ if (!db.findById(table, req.params.id)) return res.status(404).json({ error: 'Not found' });
199
+ if (!await runMiddlewares('beforeUpdate', entity, req, res, sdk)) return;
200
+ const v = validateBody(req.body, entity, core.groups);
201
+ if (v.errors) return res.status(400).json(v.errors);
202
+ fireWebhooks(entity, 'beforeUpdate', req.body);
203
+ const sanitized = sanitizeBody(req.body, entity, true);
204
+ const row = db.update(table, req.params.id, sanitized);
205
+ db.saveBelongsToMany(entity, row.id, req.body);
206
+ fireWebhooks(entity, 'afterUpdate', row);
207
+ await runMiddlewares('afterUpdate', entity, req, res, sdk);
208
+ emit(`${entity.name}.updated`, hide(row));
209
+ res.json(hide(row));
210
+ } catch (e) { res.status(400).json({ error: e.message }); }
211
+ });
212
+
213
+ // PATCH partial update
214
+ router.patch(`${base}/:id`, mw.update, async (req, res) => {
215
+ try {
216
+ if (!db.findById(table, req.params.id)) return res.status(404).json({ error: 'Not found' });
217
+ if (!await runMiddlewares('beforeUpdate', entity, req, res, sdk)) return;
218
+ const v = validateBody(req.body, entity, core.groups, { partial: true });
219
+ if (v.errors) return res.status(400).json(v.errors);
220
+ fireWebhooks(entity, 'beforeUpdate', req.body);
221
+ const row = db.update(table, req.params.id, sanitizeBody(req.body, entity));
222
+ db.saveBelongsToMany(entity, row.id, req.body);
223
+ fireWebhooks(entity, 'afterUpdate', row);
224
+ await runMiddlewares('afterUpdate', entity, req, res, sdk);
225
+ emit(`${entity.name}.updated`, hide(row));
226
+ res.json(hide(row));
227
+ } catch (e) { res.status(400).json({ error: e.message }); }
228
+ });
229
+
230
+ // DELETE
231
+ router.delete(`${base}/:id`, mw.delete, async (req, res) => {
232
+ try {
233
+ const existing = db.findById(table, req.params.id);
234
+ if (!existing) return res.status(404).json({ error: 'Not found' });
235
+ if (!await runMiddlewares('beforeDelete', entity, req, res, sdk)) return;
236
+ fireWebhooks(entity, 'beforeDelete', existing);
237
+ const row = db.remove(table, req.params.id);
238
+ fireWebhooks(entity, 'afterDelete', row);
239
+ await runMiddlewares('afterDelete', entity, req, res, sdk);
240
+ emit(`${entity.name}.deleted`, hide(row));
241
+ res.json(hide(row));
242
+ } catch (e) { res.status(500).json({ error: e.message }); }
243
+ });
244
+
245
+ logger.info(` Registered collection routes at /api/collections/${slug}`);
246
+ }
247
+ }
248
+
249
+ app.use('/api', router);
250
+ }
251
+
252
+ // ─── Policy middleware ──────────────────────────────────────────────────────
253
+
254
+ function policyMiddleware(rule, entity, core) {
255
+ const list = (entity.policies || {})[rule];
256
+ if (!list || !list.length) return [requireAuth(), _apiKeyPermGuard(rule, entity)]; // default: admin
257
+
258
+ const p = list[0];
259
+ let middlewares;
260
+ switch (p.access) {
261
+ case 'public':
262
+ middlewares = [optionalAuth, (_req, _res, next) => next()];
263
+ break;
264
+ case 'restricted': {
265
+ if (!p.allow) {
266
+ middlewares = [requireAuth()];
267
+ break;
268
+ }
269
+ const allowed = Array.isArray(p.allow) ? p.allow : [p.allow];
270
+ middlewares = [(req, res, next) => {
271
+ const { user, apiKeyPermissions, error } = resolveAuthHeader(req.headers.authorization);
272
+ if (!user) return res.status(401).json({ error: 'Authorization required' });
273
+ if (error === 'invalid_token') return res.status(401).json({ error: 'Invalid or expired token' });
274
+ if (!allowed.includes(user.entity)) return res.status(403).json({ error: 'Access denied' });
275
+ req.user = user;
276
+ if (apiKeyPermissions) req._apiKeyPermissions = apiKeyPermissions;
277
+ try {
278
+ // Ownership-based access: condition: self
279
+ if (p.condition === 'self') {
280
+ enforceSelfCondition(rule, entity, req, core);
281
+ }
282
+ next();
283
+ } catch (e) {
284
+ if (e.status) return res.status(e.status).json({ error: e.message });
285
+ return res.status(401).json({ error: 'Invalid or expired token' });
286
+ }
287
+ }];
288
+ break;
289
+ }
290
+ case 'admin':
291
+ middlewares = [requireAuth()];
292
+ break;
293
+ case 'forbidden':
294
+ middlewares = [(_req, res) => res.status(403).json({ error: 'Access forbidden' })];
295
+ break;
296
+ default:
297
+ middlewares = [(_req, _res, next) => next()];
298
+ }
299
+
300
+ // Append API key entity/operation permission guard (no-op when not using an API key)
301
+ middlewares.push(_apiKeyPermGuard(rule, entity));
302
+ return middlewares;
303
+ }
304
+
305
+ /**
306
+ * Middleware that enforces API key entity and operation restrictions.
307
+ * Only active when req._apiKeyPermissions is set (i.e. request uses an API key).
308
+ */
309
+ function _apiKeyPermGuard(operation, entity) {
310
+ return (req, res, next) => {
311
+ if (!req._apiKeyPermissions) return next();
312
+ const { operations, entities: keyEntities } = req._apiKeyPermissions;
313
+ if (operations && operations.length > 0 && !operations.includes(operation)) {
314
+ return res.status(403).json({ error: 'API key does not have permission for this operation' });
315
+ }
316
+ if (keyEntities && keyEntities.length > 0 && !keyEntities.includes(entity.slug)) {
317
+ return res.status(403).json({ error: 'API key does not have access to this entity' });
318
+ }
319
+ next();
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Enforce `condition: self` for ownership-based access.
325
+ * Depending on the rule:
326
+ * - create: ensure the FK for the user's entity points to the logged-in user
327
+ * - read: will be handled in the query (filter)
328
+ * - update: ensure the record belongs to the user, disallow ownership change
329
+ * - delete: ensure the record belongs to the user
330
+ */
331
+ function enforceSelfCondition(rule, entity, req, core) {
332
+ const userId = req.user.id;
333
+ const userEntity = req.user.entity;
334
+
335
+ // Find the FK column for this user's entity
336
+ const userEntityObj = core.entities[userEntity];
337
+ if (!userEntityObj) return;
338
+ const fk = `${userEntityObj.tableName}_id`;
339
+
340
+ if (rule === 'create') {
341
+ // Ensure the body's FK points to logged-in user
342
+ if (req.body && req.body[fk] && req.body[fk] !== userId) {
343
+ const err = new Error('Cannot create records for another user');
344
+ err.status = 403;
345
+ throw err;
346
+ }
347
+ if (req.body) req.body[fk] = userId;
348
+ } else if (rule === 'read') {
349
+ // Force a FK filter so only the user's own records are returned
350
+ req._selfFilter = { fk, userId };
351
+ } else if (rule === 'update' || rule === 'delete') {
352
+ // Verify the record belongs to the user
353
+ if (req.params && req.params.id) {
354
+ const row = db.findById(entity.tableName, req.params.id);
355
+ if (row && row[fk] !== userId) {
356
+ const err = new Error('Access denied: record does not belong to you');
357
+ err.status = 403;
358
+ throw err;
359
+ }
360
+ // For update, disallow changing ownership
361
+ if (rule === 'update' && req.body && req.body[fk] && req.body[fk] !== userId) {
362
+ const err = new Error('Cannot transfer ownership');
363
+ err.status = 403;
364
+ throw err;
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ // ─── Middleware execution ───────────────────────────────────────────────────
371
+
372
+ /**
373
+ * Run entity middlewares for a lifecycle event.
374
+ * Returns false if a middleware sent a response (halting the pipeline).
375
+ * The ChadStart backend SDK is passed to functions as the third argument.
376
+ */
377
+ async function runMiddlewares(event, entity, req, res, sdk) {
378
+ const mws = (entity.middlewares || {})[event];
379
+ if (!mws || !mws.length) return true;
380
+
381
+ for (const mw of mws) {
382
+ if (!mw.function) continue;
383
+ const fnName = mw.function.endsWith('.js') ? mw.function : `${mw.function}.js`;
384
+ const fnFile = path.resolve(
385
+ process.env.CHADSTART_FUNCTIONS_FOLDER || 'functions',
386
+ fnName
387
+ );
388
+ if (!fs.existsSync(fnFile)) {
389
+ logger.warn(`Middleware function not found: ${fnFile}`);
390
+ continue;
391
+ }
392
+ try {
393
+ const fn = require(fnFile);
394
+ await fn(req, res, sdk);
395
+ if (res.headersSent) return false;
396
+ } catch (e) {
397
+ logger.error(`Middleware ${event}/${mw.function} error: ${e.message}`);
398
+ }
399
+ }
400
+ return true;
401
+ }
402
+
403
+ // ─── Validation ─────────────────────────────────────────────────────────────
404
+
405
+ const VALIDATORS = {
406
+ required: (v) => v !== undefined && v !== null && v !== '',
407
+ isDefined: (v) => v !== undefined && v !== null,
408
+ isNotEmpty: (v) => v !== undefined && v !== null && v !== '',
409
+ isEmpty: (v) => v === undefined || v === null || v === '',
410
+ minLength: (v, n) => typeof v === 'string' && v.length >= n,
411
+ maxLength: (v, n) => typeof v === 'string' && v.length <= n,
412
+ min: (v, n) => typeof v === 'number' && v >= n,
413
+ max: (v, n) => typeof v === 'number' && v <= n,
414
+ contains: (v, s) => typeof v === 'string' && v.includes(s),
415
+ notContains: (v, s) => typeof v === 'string' && !v.includes(s),
416
+ isEmail: (v) => typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
417
+ matches: (v, p) => typeof v === 'string' && new RegExp(p).test(v),
418
+ isIn: (v, arr) => Array.isArray(arr) && arr.includes(v),
419
+ isNotIn: (v, arr) => Array.isArray(arr) && !arr.includes(v),
420
+ equals: (v, e) => v === e,
421
+ notEquals: (v, e) => v !== e,
422
+ isAlpha: (v) => typeof v === 'string' && /^[a-zA-Z]+$/.test(v),
423
+ isAlphanumeric: (v) => typeof v === 'string' && /^[a-zA-Z0-9]+$/.test(v),
424
+ isAscii: (v) => typeof v === 'string' && /^[\x00-\x7F]+$/.test(v),
425
+ isJSON: (v) => { try { JSON.parse(v); return true; } catch { return false; } },
426
+ isMimeType: (v) => typeof v === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/.test(v),
427
+ };
428
+
429
+ function validateBody(body, entity, groups, opts) {
430
+ const partial = opts && opts.partial;
431
+ const errors = [];
432
+ for (const [prop, rules] of Object.entries(entity.validation || {})) {
433
+ const val = body ? body[prop] : undefined;
434
+ // In partial (PATCH) mode, skip validation for fields not sent in the body
435
+ if (partial && val === undefined) continue;
436
+ if (rules.isOptional && (val === undefined || val === null)) continue;
437
+ const constraints = {};
438
+ for (const [name, param] of Object.entries(rules)) {
439
+ if (name === 'isOptional') continue;
440
+ const fn = VALIDATORS[name];
441
+ if (fn && !fn(val, param)) {
442
+ constraints[name] = `Validation failed: ${name}`;
443
+ }
444
+ }
445
+ if (Object.keys(constraints).length) errors.push({ property: prop, constraints });
446
+ }
447
+
448
+ // Validate group properties against group-level validation rules
449
+ if (groups) {
450
+ for (const p of entity.properties || []) {
451
+ if (p.type !== 'group') continue;
452
+ const val = body ? body[p.name] : undefined;
453
+ if (val === undefined || val === null) continue;
454
+ const groupName = p.options && p.options.group;
455
+ const groupDef = groups[groupName];
456
+ if (!groupDef || !groupDef.validation) continue;
457
+ const multiple = !p.options || p.options.multiple !== false;
458
+ const rawItems = multiple ? (Array.isArray(val) ? val : []) : [val];
459
+ rawItems.forEach((item, idx) => {
460
+ // Skip validation if item is not an object (e.g. primitives or invalid payloads)
461
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return;
462
+ for (const [propName, rules] of Object.entries(groupDef.validation)) {
463
+ const itemVal = item[propName];
464
+ if (rules.isOptional && (itemVal === undefined || itemVal === null)) continue;
465
+ const constraints = {};
466
+ for (const [name, param] of Object.entries(rules)) {
467
+ if (name === 'isOptional') continue;
468
+ const fn = VALIDATORS[name];
469
+ if (fn && !fn(itemVal, param)) {
470
+ constraints[name] = `Validation failed: ${name}`;
471
+ }
472
+ }
473
+ if (Object.keys(constraints).length) {
474
+ // For single (non-multiple) groups use `prop.subProp`; for lists use `prop[idx].subProp`
475
+ const errorPath = multiple
476
+ ? `${p.name}[${idx}].${propName}`
477
+ : `${p.name}.${propName}`;
478
+ errors.push({ property: errorPath, constraints });
479
+ }
480
+ }
481
+ });
482
+ }
483
+ }
484
+
485
+ return errors.length ? { errors } : { errors: null };
486
+ }
487
+
488
+ // ─── Webhooks ───────────────────────────────────────────────────────────────
489
+
490
+ function fireWebhooks(entity, event, record) {
491
+ for (const hook of (entity.hooks || {})[event] || []) {
492
+ if (!hook.url) continue;
493
+ const method = (hook.method || 'POST').toUpperCase();
494
+ const headers = { 'Content-Type': 'application/json', ...(hook.headers || {}) };
495
+ for (const [k, v] of Object.entries(headers)) {
496
+ if (typeof v === 'string') headers[k] = v.replace(/\$\{([^}]+)\}/g, (_, e) => process.env[e] || '');
497
+ }
498
+ const body = JSON.stringify({ event, createdAt: new Date().toISOString(), entity: entity.slug, record });
499
+ fetch(hook.url, { method, headers, body: method !== 'GET' ? body : undefined }).catch((err) => {
500
+ logger.error(`Webhook ${event} to ${hook.url} failed: ${err.message}`);
501
+ });
502
+ }
503
+ }
504
+
505
+ // ─── Helpers ────────────────────────────────────────────────────────────────
506
+
507
+ /**
508
+ * Filter out hidden properties from API responses.
509
+ */
510
+ function hideHiddenProps(row, entity) {
511
+ if (!row || !entity.properties) return row;
512
+ const hiddenNames = new Set(entity.properties.filter((p) => p.hidden).map((p) => p.name));
513
+ if (!hiddenNames.size) return row;
514
+ return Object.fromEntries(Object.entries(row).filter(([k]) => !hiddenNames.has(k)));
515
+ }
516
+
517
+ /**
518
+ * Apply default property values for missing fields.
519
+ */
520
+ function applyDefaults(body, entity) {
521
+ const result = { ...(body || {}) };
522
+ for (const p of entity.properties) {
523
+ if (p.default !== undefined && (result[p.name] === undefined || result[p.name] === null)) {
524
+ // Coerce boolean defaults to SQLite integers (1/0)
525
+ if (p.type === 'boolean' || p.type === 'bool') {
526
+ result[p.name] = p.default ? 1 : 0;
527
+ } else {
528
+ result[p.name] = p.default;
529
+ }
530
+ }
531
+ }
532
+ return result;
533
+ }
534
+
535
+ /**
536
+ * Sanitize request body to only include valid property names and FK columns.
537
+ * fullReplace: if true, set missing properties to null (for PUT).
538
+ */
539
+ function sanitizeBody(body, entity, fullReplace) {
540
+ if (!body || typeof body !== 'object') return {};
541
+ const allowed = new Set(entity.properties.map((p) => p.name));
542
+ for (const rel of entity.belongsTo || []) {
543
+ const name = typeof rel === 'string' ? rel : (rel.entity || rel.name);
544
+ allowed.add(`${toSnakeCase(name)}_id`);
545
+ // Also allow camelCase form (e.g., "teamId" -> "team_id")
546
+ allowed.add(`${name.charAt(0).toLowerCase() + name.slice(1)}Id`);
547
+ }
548
+
549
+ const result = {};
550
+ if (fullReplace) {
551
+ // PUT: set all allowed props — missing ones become null
552
+ for (const key of allowed) {
553
+ result[key] = body[key] !== undefined ? body[key] : null;
554
+ }
555
+ } else {
556
+ // PATCH: only include props present in body
557
+ for (const [k, v] of Object.entries(body)) {
558
+ if (allowed.has(k)) result[k] = v;
559
+ }
560
+ }
561
+
562
+ // Convert camelCase FK keys to snake_case (e.g., teamId -> team_id)
563
+ for (const rel of entity.belongsTo || []) {
564
+ const name = typeof rel === 'string' ? rel : (rel.entity || rel.name);
565
+ const camelKey = `${name.charAt(0).toLowerCase() + name.slice(1)}Id`;
566
+ const snakeKey = `${toSnakeCase(name)}_id`;
567
+ if (result[camelKey] !== undefined) {
568
+ result[snakeKey] = result[camelKey];
569
+ delete result[camelKey];
570
+ }
571
+ }
572
+
573
+ // Serialize group properties to JSON strings for SQLite TEXT storage
574
+ // Coerce boolean properties to SQLite integers (1/0)
575
+ for (const p of entity.properties) {
576
+ if (p.type === 'group' && result[p.name] !== undefined && result[p.name] !== null) {
577
+ if (typeof result[p.name] !== 'string') {
578
+ result[p.name] = JSON.stringify(result[p.name]);
579
+ }
580
+ }
581
+ if ((p.type === 'boolean' || p.type === 'bool') && result[p.name] !== undefined && result[p.name] !== null) {
582
+ result[p.name] = result[p.name] ? 1 : 0;
583
+ }
584
+ }
585
+
586
+ return result;
587
+ }
588
+
589
+ /**
590
+ * Parse group-type properties from JSON strings back to JS objects/arrays.
591
+ * Called before returning rows to the client.
592
+ */
593
+ function deserializeGroupProps(row, entity) {
594
+ if (!row || !entity.properties) return row;
595
+ const hasGroups = entity.properties.some((p) => p.type === 'group');
596
+ if (!hasGroups) return row;
597
+ const result = { ...row };
598
+ for (const p of entity.properties) {
599
+ if (p.type === 'group' && result[p.name] && typeof result[p.name] === 'string') {
600
+ try { result[p.name] = JSON.parse(result[p.name]); } catch { /* leave as string if invalid JSON */ }
601
+ }
602
+ }
603
+ return result;
604
+ }
605
+
606
+ module.exports = { registerApiRoutes, validateBody, applyDefaults, hideHiddenProps, deserializeGroupProps, createBackendSdk };