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,166 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Convert the raw YAML config into normalized internal models.
5
+ *
6
+ * Authenticable entities (user collections) are entities with
7
+ * `authenticable: true` — no separate userCollections section.
8
+ */
9
+
10
+ const EMOJI_ACCESS = { '🌐': 'public', '🔒': 'restricted', '👨🏻‍💻': 'admin', '🚫': 'forbidden' };
11
+
12
+ function normalizePolicies(raw) {
13
+ if (!raw) return {};
14
+ const out = {};
15
+ for (const [rule, list] of Object.entries(raw)) {
16
+ out[rule] = list.map((p) => ({
17
+ access: EMOJI_ACCESS[p.access] || p.access,
18
+ allow: p.allow || null,
19
+ condition: p.condition || null,
20
+ }));
21
+ }
22
+ return out;
23
+ }
24
+
25
+ function normalizeRelation(rel) {
26
+ if (typeof rel === 'string') return { name: rel, entity: rel };
27
+ return { name: rel.name || rel.entity, entity: rel.entity || rel.name, ...rel };
28
+ }
29
+
30
+ function normalizeProperty(prop) {
31
+ if (typeof prop === 'string') return { name: prop, type: 'string' };
32
+ return {
33
+ name: prop.name,
34
+ type: prop.type || 'string',
35
+ hidden: prop.hidden === true,
36
+ default: prop.default !== undefined ? prop.default : undefined,
37
+ options: prop.options || undefined,
38
+ helpText: prop.helpText || undefined,
39
+ validation: prop.validation || undefined,
40
+ };
41
+ }
42
+
43
+ function buildEntities(config) {
44
+ const entities = {};
45
+
46
+ for (const [name, def] of Object.entries(config.entities || {})) {
47
+ const properties = (def.properties || []).map(normalizeProperty);
48
+
49
+ // Merge inline property validation into entity-level validation.
50
+ // Inline declarations prevail over block-level on conflict.
51
+ const validation = { ...(def.validation || {}) };
52
+ for (const p of properties) {
53
+ if (p.validation) {
54
+ validation[p.name] = { ...(validation[p.name] || {}), ...p.validation };
55
+ }
56
+ }
57
+
58
+ entities[name] = {
59
+ name,
60
+ tableName: toSnakeCase(name),
61
+ slug: def.slug || toKebabCase(name),
62
+ authenticable: def.authenticable === true,
63
+ single: def.single === true,
64
+ mainProp: def.mainProp || null,
65
+ nameSingular: def.nameSingular || null,
66
+ namePlural: def.namePlural || null,
67
+ seedCount: def.seedCount || 50,
68
+ properties,
69
+ belongsTo: (def.belongsTo || []).map(normalizeRelation),
70
+ belongsToMany: (def.belongsToMany || []).map(normalizeRelation),
71
+ policies: normalizePolicies(def.policies),
72
+ validation,
73
+ hooks: def.hooks || {},
74
+ middlewares: def.middlewares || {},
75
+ };
76
+ }
77
+
78
+ return entities;
79
+ }
80
+
81
+ function getAuthenticableEntities(entities) {
82
+ return Object.fromEntries(
83
+ Object.entries(entities).filter(([, e]) => e.authenticable)
84
+ );
85
+ }
86
+
87
+ function toSnakeCase(str) {
88
+ return str.replace(/([A-Z])/g, (m, p, o) => (o > 0 ? '_' : '') + p.toLowerCase()).replace(/^_/, '');
89
+ }
90
+
91
+ function toKebabCase(str) {
92
+ return str.replace(/([A-Z])/g, (m, p, o) => (o > 0 ? '-' : '') + p.toLowerCase()).replace(/^-/, '');
93
+ }
94
+
95
+ /** Default Admin entity definition — email + password authenticable entity. */
96
+ const DEFAULT_ADMIN_ENTITY = {
97
+ authenticable: true,
98
+ mainProp: 'email',
99
+ properties: [],
100
+ };
101
+
102
+ /**
103
+ * Build the Admin entity for the built-in admin access.
104
+ * Always included unless admin.enable_entity is explicitly false.
105
+ * If the user defines an "Admin" entity in their YAML, it is merged with the default.
106
+ */
107
+ function buildAdminEntity(config) {
108
+ const adminCfg = config.admin || {};
109
+ if (adminCfg.enable_entity === false) return null;
110
+
111
+ const userDef = (config.entities || {}).Admin || {};
112
+ const merged = {
113
+ ...DEFAULT_ADMIN_ENTITY,
114
+ ...userDef,
115
+ // Always authenticable
116
+ authenticable: true,
117
+ // Merge properties: user-defined takes precedence (email/password are handled by auth system)
118
+ properties: userDef.properties || DEFAULT_ADMIN_ENTITY.properties,
119
+ };
120
+ return merged;
121
+ }
122
+
123
+ function buildCore(config) {
124
+ const adminCfg = config.admin || {};
125
+
126
+ // Inject the default Admin entity into entities map before building
127
+ const rawEntities = { ...(config.entities || {}) };
128
+ const adminEntityDef = buildAdminEntity(config);
129
+ if (adminEntityDef && !rawEntities.Admin) {
130
+ rawEntities.Admin = adminEntityDef;
131
+ } else if (adminEntityDef && rawEntities.Admin) {
132
+ // Merge: user-defined fields override defaults, but authenticable is always true
133
+ rawEntities.Admin = {
134
+ ...DEFAULT_ADMIN_ENTITY,
135
+ ...rawEntities.Admin,
136
+ authenticable: true,
137
+ };
138
+ }
139
+
140
+ const entities = buildEntities({ ...config, entities: rawEntities });
141
+
142
+ const rateLimits = config.rateLimits || null;
143
+ const telemetry = config.telemetry || null;
144
+
145
+ return {
146
+ name: config.name,
147
+ database: config.database || null,
148
+ entities,
149
+ authenticableEntities: getAuthenticableEntities(entities),
150
+ functions: config.functions || {},
151
+ groups: config.groups || {},
152
+ plugins: config.plugins || [],
153
+ files: config.files || {},
154
+ public: config.public || null,
155
+ port: parseInt(process.env.CHADSTART_PORT || process.env.PORT || config.port || 3000, 10),
156
+ rateLimits,
157
+ telemetry,
158
+ admin: {
159
+ enable_app: adminCfg.enable_app !== false,
160
+ enable_entity: adminCfg.enable_entity !== false,
161
+ policies: adminCfg.policies || [{ access: 'admin' }],
162
+ },
163
+ };
164
+ }
165
+
166
+ module.exports = { buildCore, buildEntities, getAuthenticableEntities, toSnakeCase, toKebabCase };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Error Reporting integration for ChadStart.
5
+ *
6
+ * Supports Sentry (https://sentry.io) for automatic exception tracking.
7
+ * To enable, set the SENTRY_DSN environment variable — it is treated as a
8
+ * secret and must NOT be placed in the YAML config file.
9
+ *
10
+ * Non-sensitive settings (environment label, sample rates) can be provided
11
+ * via the `sentry` section of your chadstart.yaml or via environment variables.
12
+ *
13
+ * 💡 Self-hosted alternative: Bugsink (https://www.bugsink.com) is a
14
+ * lightweight, privacy-first alternative to Sentry that is fully
15
+ * compatible with the Sentry SDK. Simply point SENTRY_DSN at your
16
+ * Bugsink instance — no other code changes required.
17
+ *
18
+ * Example chadstart.yaml:
19
+ * sentry:
20
+ * environment: production # optional (defaults to NODE_ENV)
21
+ * tracesSampleRate: 0.2 # optional (default: 1.0)
22
+ * debug: false # optional (default: false)
23
+ *
24
+ * Example .env:
25
+ * SENTRY_DSN=https://xxxxx@oXXXXX.ingest.sentry.io/XXXXXXX
26
+ */
27
+
28
+ const logger = require('../utils/logger');
29
+
30
+ let _sentry = null;
31
+ let _initialized = false;
32
+
33
+ /**
34
+ * Initialise the Sentry SDK if SENTRY_DSN is set.
35
+ *
36
+ * @param {object} core The parsed chadstart config (core object).
37
+ */
38
+ function initErrorReporter(core) {
39
+ const dsn = process.env.SENTRY_DSN;
40
+ if (!dsn) return;
41
+
42
+ /* istanbul ignore next */
43
+ try {
44
+ _sentry = require('@sentry/node');
45
+ } catch {
46
+ logger.warn('[error-reporter] SENTRY_DSN is set but @sentry/node is not installed. Run: npm install @sentry/node');
47
+ return;
48
+ }
49
+
50
+ const sentryConfig = (core && core.sentry) || {};
51
+
52
+ _sentry.init({
53
+ dsn,
54
+ environment: sentryConfig.environment || process.env.NODE_ENV || 'development',
55
+ tracesSampleRate: sentryConfig.tracesSampleRate !== undefined
56
+ ? sentryConfig.tracesSampleRate
57
+ : 1.0,
58
+ debug: sentryConfig.debug === true,
59
+ });
60
+
61
+ _initialized = true;
62
+ logger.info('[error-reporter] Error reporting enabled via Sentry');
63
+ }
64
+
65
+ /**
66
+ * Returns the Sentry Express request handler middleware, or null when Sentry
67
+ * is not configured. Must be added as the *first* middleware in the chain.
68
+ *
69
+ * @returns {import('express').RequestHandler|null}
70
+ */
71
+ function getRequestHandler() {
72
+ if (!_initialized || !_sentry) return null;
73
+ return _sentry.expressIntegration
74
+ ? null // v8+: request capture is automatic
75
+ : _sentry.Handlers.requestHandler();
76
+ }
77
+
78
+ /**
79
+ * Returns the Sentry Express error handler middleware, or null when Sentry
80
+ * is not configured. Must be added *after* all routes and before any other
81
+ * error-handling middleware.
82
+ *
83
+ * For Sentry v8+, use `attachErrorHandler(app)` instead — this function
84
+ * returns null for v8 since error handling is registered via
85
+ * `setupExpressErrorHandler`.
86
+ *
87
+ * @returns {import('express').ErrorRequestHandler|null}
88
+ */
89
+ function getErrorHandler() {
90
+ if (!_initialized || !_sentry) return null;
91
+ // v8+ registers error handling differently via setupExpressErrorHandler;
92
+ // use attachErrorHandler(app) for v8.
93
+ if (typeof _sentry.setupExpressErrorHandler === 'function') return null;
94
+ /* istanbul ignore next */
95
+ return _sentry.Handlers.errorHandler();
96
+ }
97
+
98
+ /**
99
+ * Attach Sentry error-handler middleware to an Express app (v8 compatible).
100
+ * Call this after registering all routes.
101
+ *
102
+ * @param {import('express').Application} app
103
+ */
104
+ function attachErrorHandler(app) {
105
+ if (!_initialized || !_sentry) return;
106
+ if (typeof _sentry.setupExpressErrorHandler === 'function') {
107
+ _sentry.setupExpressErrorHandler(app);
108
+ } else {
109
+ /* istanbul ignore next */
110
+ app.use(_sentry.Handlers.errorHandler());
111
+ }
112
+ }
113
+
114
+ /** Expose the initialised Sentry instance (useful for manual captures). */
115
+ function getSentry() {
116
+ return _initialized ? _sentry : null;
117
+ }
118
+
119
+ /** Reset state (used in tests). */
120
+ function _reset() {
121
+ _sentry = null;
122
+ _initialized = false;
123
+ }
124
+
125
+ module.exports = {
126
+ initErrorReporter,
127
+ getRequestHandler,
128
+ getErrorHandler,
129
+ attachErrorHandler,
130
+ getSentry,
131
+ _reset,
132
+ };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const express = require('express');
6
+ const logger = require('../utils/logger');
7
+
8
+ /**
9
+ * Register file storage routes for all buckets defined in core.files.
10
+ * Each bucket exposes:
11
+ * POST /files/<bucket> — upload (multipart/form-data, field name "file")
12
+ * GET /files/<bucket>/:file — download
13
+ */
14
+ function registerFileRoutes(app, core) {
15
+ const cwd = process.cwd();
16
+
17
+ for (const [bucketName, bucketDef] of Object.entries(core.files)) {
18
+ const bucketPath = path.resolve(bucketDef.path);
19
+
20
+ // Validate that the bucket path stays within the working directory
21
+ if (!bucketPath.startsWith(cwd + path.sep) && bucketPath !== cwd) {
22
+ throw new Error(
23
+ `File bucket "${bucketName}" path "${bucketDef.path}" resolves outside the working directory.`
24
+ );
25
+ }
26
+
27
+ // Ensure the upload directory exists
28
+ fs.mkdirSync(bucketPath, { recursive: true });
29
+
30
+ // Serve files statically if public
31
+ if (bucketDef.public !== false) {
32
+ app.use(`/files/${bucketName}`, express.static(bucketPath));
33
+ }
34
+
35
+ // Upload endpoint — uses raw multipart parsing via busboy
36
+ app.post(`/files/${bucketName}`, (req, res) => {
37
+ const contentType = req.headers['content-type'] || '';
38
+ if (!contentType.includes('multipart/form-data')) {
39
+ return res.status(400).json({ error: 'Expected multipart/form-data' });
40
+ }
41
+
42
+ const Busboy = getBusboy();
43
+ const busboy = Busboy({ headers: req.headers });
44
+ let saved = false;
45
+ let savedName = null;
46
+
47
+ busboy.on('file', (fieldname, file, info) => {
48
+ const { filename } = info;
49
+ if (!filename) {
50
+ file.resume();
51
+ return;
52
+ }
53
+ // Sanitize filename — strip directory traversal and disallow problematic characters
54
+ const safeName = path
55
+ .basename(filename)
56
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
57
+ .replace(/^\.+/, '_');
58
+ const dest = path.join(bucketPath, safeName);
59
+ const writeStream = fs.createWriteStream(dest);
60
+ file.pipe(writeStream);
61
+ writeStream.on('finish', () => {
62
+ saved = true;
63
+ savedName = safeName;
64
+ });
65
+ });
66
+
67
+ busboy.on('finish', () => {
68
+ if (saved) {
69
+ res.json({ file: savedName, url: `/files/${bucketName}/${savedName}` });
70
+ } else {
71
+ res.status(400).json({ error: 'No file field found in upload' });
72
+ }
73
+ });
74
+
75
+ busboy.on('error', (err) => {
76
+ logger.error('Upload error', err.message);
77
+ res.status(500).json({ error: err.message });
78
+ });
79
+
80
+ req.pipe(busboy);
81
+ });
82
+
83
+ logger.info(` Registered file bucket "${bucketName}" at /files/${bucketName} -> ${bucketPath}`);
84
+ }
85
+ }
86
+
87
+ function getBusboy() {
88
+ try {
89
+ return require('busboy');
90
+ } catch {
91
+ throw new Error(
92
+ 'busboy is required for file uploads. Install it with: npm install busboy'
93
+ );
94
+ }
95
+ }
96
+
97
+ module.exports = { registerFileRoutes };