chadstart 1.0.0 → 1.0.1

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.
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "chadstart",
3
+ "image": "mcr.microsoft.com/devcontainers/javascript-node:24-trixie",
4
+ "features": {},
5
+ "onCreateCommand": "sudo apt-get update && sudo apt-get install -y python3 make g++ curl wget && npm ci",
6
+ "postCreateCommand": "cp -n .env.example .env || true",
7
+ "forwardPorts": [3000],
8
+ "portsAttributes": {
9
+ "3000": {
10
+ "label": "chadstart API",
11
+ "onAutoForward": "notify"
12
+ }
13
+ },
14
+ "customizations": {
15
+ "vscode": {
16
+ "extensions": [
17
+ "dbaeumer.vscode-eslint",
18
+ "esbenp.prettier-vscode",
19
+ "redhat.vscode-yaml",
20
+ "humao.rest-client",
21
+ "rangav.vscode-thunder-client",
22
+ "pkief.material-icon-theme"
23
+ ],
24
+ "settings": {
25
+ "editor.formatOnSave": true,
26
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
27
+ "files.eol": "\n"
28
+ }
29
+ }
30
+ },
31
+ "remoteEnv": {
32
+ "NODE_ENV": "development"
33
+ }
34
+ }
package/.env.example CHANGED
@@ -14,7 +14,16 @@ TOKEN_SECRET_KEY=replace-with-a-long-random-secret
14
14
  # CHADSTART_FUNCTIONS_FOLDER=functions
15
15
 
16
16
  # Database (SQLite default)
17
- # DB_PATH=data/chadstart.db
17
+ # DB_ENGINE=sqlite # sqlite (default), postgres, or mysql
18
+ # DB_PATH=/data/chadstart.db # SQLite: path to database file
19
+
20
+ # PostgreSQL / MySQL / MariaDB (set DB_ENGINE=postgres or mysql to activate)
21
+ # DB_HOST=localhost
22
+ # DB_PORT=5432 # 5432 for PostgreSQL, 3306 for MySQL/MariaDB
23
+ # DB_USERNAME=postgres
24
+ # DB_PASSWORD=postgres
25
+ # DB_DATABASE=manifest
26
+ # DB_SSL=false # Set to true for remote managed databases
18
27
 
19
28
  # Optional: S3-compatible storage (when set, files are stored in S3 instead of the local filesystem)
20
29
  # S3_BUCKET=my-bucket-name
@@ -0,0 +1,139 @@
1
+ name: DB Integration Tests
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ # ── PostgreSQL ──────────────────────────────────────────────────────────────
10
+ test-postgres:
11
+ name: Integration – PostgreSQL
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+
16
+ services:
17
+ postgres:
18
+ image: postgres:16
19
+ env:
20
+ POSTGRES_PASSWORD: postgres
21
+ POSTGRES_DB: chadstart_test
22
+ ports:
23
+ - 5432:5432
24
+ options: >-
25
+ --health-cmd pg_isready
26
+ --health-interval 10s
27
+ --health-timeout 5s
28
+ --health-retries 5
29
+
30
+ steps:
31
+ - name: Checkout
32
+ uses: actions/checkout@v4
33
+
34
+ - name: Set up Node.js
35
+ uses: actions/setup-node@v4
36
+ with:
37
+ node-version: "lts/*"
38
+ cache: "npm"
39
+
40
+ - name: Install dependencies
41
+ run: npm ci
42
+
43
+ - name: Run integration tests
44
+ run: npm run test:integration
45
+ env:
46
+ DB_ENGINE: postgres
47
+ DB_HOST: localhost
48
+ DB_PORT: 5432
49
+ DB_USERNAME: postgres
50
+ DB_PASSWORD: postgres
51
+ DB_DATABASE: chadstart_test
52
+
53
+ # ── MySQL ───────────────────────────────────────────────────────────────────
54
+ test-mysql:
55
+ name: Integration – MySQL
56
+ runs-on: ubuntu-latest
57
+ permissions:
58
+ contents: read
59
+
60
+ services:
61
+ mysql:
62
+ image: mysql:8
63
+ env:
64
+ MYSQL_ROOT_PASSWORD: root
65
+ MYSQL_DATABASE: chadstart_test
66
+ ports:
67
+ - 3306:3306
68
+ options: >-
69
+ --health-cmd "mysqladmin ping -h localhost -u root -proot"
70
+ --health-interval 10s
71
+ --health-timeout 5s
72
+ --health-retries 10
73
+
74
+ steps:
75
+ - name: Checkout
76
+ uses: actions/checkout@v4
77
+
78
+ - name: Set up Node.js
79
+ uses: actions/setup-node@v4
80
+ with:
81
+ node-version: "lts/*"
82
+ cache: "npm"
83
+
84
+ - name: Install dependencies
85
+ run: npm ci
86
+
87
+ - name: Run integration tests
88
+ run: npm run test:integration
89
+ env:
90
+ DB_ENGINE: mysql
91
+ DB_HOST: 127.0.0.1
92
+ DB_PORT: 3306
93
+ DB_USERNAME: root
94
+ DB_PASSWORD: root
95
+ DB_DATABASE: chadstart_test
96
+
97
+ # ── MariaDB ─────────────────────────────────────────────────────────────────
98
+ test-mariadb:
99
+ name: Integration – MariaDB
100
+ runs-on: ubuntu-latest
101
+ permissions:
102
+ contents: read
103
+
104
+ services:
105
+ mariadb:
106
+ image: mariadb:11
107
+ env:
108
+ MARIADB_ROOT_PASSWORD: root
109
+ MARIADB_DATABASE: chadstart_test
110
+ ports:
111
+ - 3306:3306
112
+ options: >-
113
+ --health-cmd "healthcheck.sh --connect --innodb_initialized"
114
+ --health-interval 10s
115
+ --health-timeout 5s
116
+ --health-retries 10
117
+
118
+ steps:
119
+ - name: Checkout
120
+ uses: actions/checkout@v4
121
+
122
+ - name: Set up Node.js
123
+ uses: actions/setup-node@v4
124
+ with:
125
+ node-version: "lts/*"
126
+ cache: "npm"
127
+
128
+ - name: Install dependencies
129
+ run: npm ci
130
+
131
+ - name: Run integration tests
132
+ run: npm run test:integration
133
+ env:
134
+ DB_ENGINE: mysql
135
+ DB_HOST: 127.0.0.1
136
+ DB_PORT: 3306
137
+ DB_USERNAME: root
138
+ DB_PASSWORD: root
139
+ DB_DATABASE: chadstart_test
@@ -9,7 +9,7 @@ jobs:
9
9
  name: NPM Publish
10
10
  runs-on: ubuntu-latest
11
11
  permissions:
12
- contents: read
12
+ contents: write
13
13
  id-token: write # required for npm provenance
14
14
  steps:
15
15
  - name: Checkout repository
@@ -21,6 +21,17 @@ jobs:
21
21
  node-version: '24'
22
22
  registry-url: 'https://registry.npmjs.org'
23
23
 
24
+ - name: Configure git
25
+ run: |
26
+ git config user.name "github-actions[bot]"
27
+ git config user.email "github-actions[bot]@users.noreply.github.com"
28
+
29
+ - name: Bump patch version
30
+ run: npm version patch --message "v%s [skip ci]"
31
+
32
+ - name: Push version bump
33
+ run: git push --follow-tags
34
+
24
35
  - name: Publish to npm
25
36
  run: npm publish --access public --provenance
26
37
  env:
@@ -14,7 +14,7 @@ jobs:
14
14
  run:
15
15
  working-directory: sdk
16
16
  permissions:
17
- contents: read
17
+ contents: write
18
18
  id-token: write # required for npm provenance
19
19
  steps:
20
20
  - name: Checkout repository
@@ -32,6 +32,17 @@ jobs:
32
32
  - name: Run SDK tests
33
33
  run: node test/sdk.test.cjs
34
34
 
35
+ - name: Configure git
36
+ run: |
37
+ git config user.name "github-actions[bot]"
38
+ git config user.email "github-actions[bot]@users.noreply.github.com"
39
+
40
+ - name: Bump patch version
41
+ run: npm version patch --message "v%s [skip ci]"
42
+
43
+ - name: Push version bump
44
+ run: git push --follow-tags
45
+
35
46
  - name: Publish to npm
36
47
  run: npm publish --access public --provenance
37
48
  env:
@@ -38,17 +38,17 @@ function createBackendSdk(core) {
38
38
  if (!entity) throw new Error(`Single entity not found for slug: ${slug}`);
39
39
  const table = entity.tableName;
40
40
  return {
41
- get() {
42
- const rows = db.findAllSimple(table);
41
+ async get() {
42
+ const rows = await db.findAllSimple(table);
43
43
  return rows[0] || null;
44
44
  },
45
- update(data) {
46
- const rows = db.findAllSimple(table);
45
+ async update(data) {
46
+ const rows = await db.findAllSimple(table);
47
47
  if (!rows[0]) return null;
48
48
  return db.update(table, rows[0].id, data);
49
49
  },
50
- patch(data) {
51
- const rows = db.findAllSimple(table);
50
+ async patch(data) {
51
+ const rows = await db.findAllSimple(table);
52
52
  if (!rows[0]) return null;
53
53
  return db.update(table, rows[0].id, data);
54
54
  },
@@ -82,9 +82,9 @@ function registerApiRoutes(app, core, emit) {
82
82
  };
83
83
 
84
84
  // GET single
85
- router.get(base, mw.read, (_req, res) => {
85
+ router.get(base, mw.read, async (_req, res) => {
86
86
  try {
87
- const rows = db.findAllSimple(table);
87
+ const rows = await db.findAllSimple(table);
88
88
  const row = rows[0];
89
89
  if (!row) return res.status(404).json({ error: 'Not found' });
90
90
  res.json(hide(row));
@@ -94,7 +94,7 @@ function registerApiRoutes(app, core, emit) {
94
94
  // PUT single (full replace)
95
95
  router.put(base, mw.update, async (req, res) => {
96
96
  try {
97
- const rows = db.findAllSimple(table);
97
+ const rows = await db.findAllSimple(table);
98
98
  const row = rows[0];
99
99
  if (!row) return res.status(404).json({ error: 'Not found' });
100
100
  if (!await runMiddlewares('beforeUpdate', entity, req, res, sdk)) return;
@@ -102,7 +102,7 @@ function registerApiRoutes(app, core, emit) {
102
102
  if (v.errors) return res.status(400).json(v.errors);
103
103
  fireWebhooks(entity, 'beforeUpdate', req.body);
104
104
  const sanitized = sanitizeBody(req.body, entity, true);
105
- const updated = db.update(table, row.id, sanitized);
105
+ const updated = await db.update(table, row.id, sanitized);
106
106
  fireWebhooks(entity, 'afterUpdate', updated);
107
107
  await runMiddlewares('afterUpdate', entity, req, res, sdk);
108
108
  emit(`${entity.name}.updated`, hide(updated));
@@ -113,14 +113,14 @@ function registerApiRoutes(app, core, emit) {
113
113
  // PATCH single (partial)
114
114
  router.patch(base, mw.update, async (req, res) => {
115
115
  try {
116
- const rows = db.findAllSimple(table);
116
+ const rows = await db.findAllSimple(table);
117
117
  const row = rows[0];
118
118
  if (!row) return res.status(404).json({ error: 'Not found' });
119
119
  if (!await runMiddlewares('beforeUpdate', entity, req, res, sdk)) return;
120
120
  const v = validateBody(req.body, entity, core.groups, { partial: true });
121
121
  if (v.errors) return res.status(400).json(v.errors);
122
122
  fireWebhooks(entity, 'beforeUpdate', req.body);
123
- const updated = db.update(table, row.id, sanitizeBody(req.body, entity));
123
+ const updated = await db.update(table, row.id, sanitizeBody(req.body, entity));
124
124
  fireWebhooks(entity, 'afterUpdate', updated);
125
125
  await runMiddlewares('afterUpdate', entity, req, res, sdk);
126
126
  emit(`${entity.name}.updated`, hide(updated));
@@ -140,37 +140,37 @@ function registerApiRoutes(app, core, emit) {
140
140
  };
141
141
 
142
142
  // GET list (paginated)
143
- router.get(base, mw.read, (req, res) => {
143
+ router.get(base, mw.read, async (req, res) => {
144
144
  try {
145
145
  // Ownership filter: condition: self forces a FK filter on the current user
146
146
  const query = req._selfFilter
147
147
  ? { ...req.query, [req._selfFilter.fk]: req._selfFilter.userId }
148
148
  : req.query;
149
- const result = db.findAll(table, query, {
149
+ const result = await db.findAll(table, query, {
150
150
  page: req.query.page,
151
151
  perPage: req.query.perPage,
152
152
  orderBy: req.query.orderBy,
153
153
  order: req.query.order,
154
154
  });
155
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
- });
156
+ for (const row of result.data) {
157
+ if (relations) await db.loadRelations(row, entity, relations);
158
+ }
159
+ result.data = result.data.map((row) => hide(row));
160
160
  res.json(result);
161
161
  } catch (e) { res.status(500).json({ error: e.message }); }
162
162
  });
163
163
 
164
164
  // GET single by id
165
- router.get(`${base}/:id`, mw.read, (req, res) => {
165
+ router.get(`${base}/:id`, mw.read, async (req, res) => {
166
166
  try {
167
- const row = db.findById(table, req.params.id);
167
+ const row = await db.findById(table, req.params.id);
168
168
  if (!row) return res.status(404).json({ error: 'Not found' });
169
169
  // Ownership check for read with condition: self
170
170
  if (req._selfFilter && row[req._selfFilter.fk] !== req._selfFilter.userId) {
171
171
  return res.status(403).json({ error: 'Access denied' });
172
172
  }
173
- if (req.query.relations) db.loadRelations(row, entity, req.query.relations);
173
+ if (req.query.relations) await db.loadRelations(row, entity, req.query.relations);
174
174
  res.json(hide(row));
175
175
  } catch (e) { res.status(500).json({ error: e.message }); }
176
176
  });
@@ -183,8 +183,8 @@ function registerApiRoutes(app, core, emit) {
183
183
  const v = validateBody(body, entity, core.groups);
184
184
  if (v.errors) return res.status(400).json(v.errors);
185
185
  fireWebhooks(entity, 'beforeCreate', body);
186
- const row = db.create(table, sanitizeBody(body, entity));
187
- db.saveBelongsToMany(entity, row.id, req.body);
186
+ const row = await db.create(table, sanitizeBody(body, entity));
187
+ await db.saveBelongsToMany(entity, row.id, req.body);
188
188
  fireWebhooks(entity, 'afterCreate', row);
189
189
  await runMiddlewares('afterCreate', entity, req, res, sdk);
190
190
  emit(`${entity.name}.created`, hide(row));
@@ -195,14 +195,14 @@ function registerApiRoutes(app, core, emit) {
195
195
  // PUT full replace
196
196
  router.put(`${base}/:id`, mw.update, async (req, res) => {
197
197
  try {
198
- if (!db.findById(table, req.params.id)) return res.status(404).json({ error: 'Not found' });
198
+ if (!await db.findById(table, req.params.id)) return res.status(404).json({ error: 'Not found' });
199
199
  if (!await runMiddlewares('beforeUpdate', entity, req, res, sdk)) return;
200
200
  const v = validateBody(req.body, entity, core.groups);
201
201
  if (v.errors) return res.status(400).json(v.errors);
202
202
  fireWebhooks(entity, 'beforeUpdate', req.body);
203
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);
204
+ const row = await db.update(table, req.params.id, sanitized);
205
+ await db.saveBelongsToMany(entity, row.id, req.body);
206
206
  fireWebhooks(entity, 'afterUpdate', row);
207
207
  await runMiddlewares('afterUpdate', entity, req, res, sdk);
208
208
  emit(`${entity.name}.updated`, hide(row));
@@ -213,13 +213,13 @@ function registerApiRoutes(app, core, emit) {
213
213
  // PATCH partial update
214
214
  router.patch(`${base}/:id`, mw.update, async (req, res) => {
215
215
  try {
216
- if (!db.findById(table, req.params.id)) return res.status(404).json({ error: 'Not found' });
216
+ if (!await db.findById(table, req.params.id)) return res.status(404).json({ error: 'Not found' });
217
217
  if (!await runMiddlewares('beforeUpdate', entity, req, res, sdk)) return;
218
218
  const v = validateBody(req.body, entity, core.groups, { partial: true });
219
219
  if (v.errors) return res.status(400).json(v.errors);
220
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);
221
+ const row = await db.update(table, req.params.id, sanitizeBody(req.body, entity));
222
+ await db.saveBelongsToMany(entity, row.id, req.body);
223
223
  fireWebhooks(entity, 'afterUpdate', row);
224
224
  await runMiddlewares('afterUpdate', entity, req, res, sdk);
225
225
  emit(`${entity.name}.updated`, hide(row));
@@ -230,11 +230,11 @@ function registerApiRoutes(app, core, emit) {
230
230
  // DELETE
231
231
  router.delete(`${base}/:id`, mw.delete, async (req, res) => {
232
232
  try {
233
- const existing = db.findById(table, req.params.id);
233
+ const existing = await db.findById(table, req.params.id);
234
234
  if (!existing) return res.status(404).json({ error: 'Not found' });
235
235
  if (!await runMiddlewares('beforeDelete', entity, req, res, sdk)) return;
236
236
  fireWebhooks(entity, 'beforeDelete', existing);
237
- const row = db.remove(table, req.params.id);
237
+ const row = await db.remove(table, req.params.id);
238
238
  fireWebhooks(entity, 'afterDelete', row);
239
239
  await runMiddlewares('afterDelete', entity, req, res, sdk);
240
240
  emit(`${entity.name}.deleted`, hide(row));
@@ -267,8 +267,8 @@ function policyMiddleware(rule, entity, core) {
267
267
  break;
268
268
  }
269
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);
270
+ middlewares = [async (req, res, next) => {
271
+ const { user, apiKeyPermissions, error } = await resolveAuthHeader(req.headers.authorization);
272
272
  if (!user) return res.status(401).json({ error: 'Authorization required' });
273
273
  if (error === 'invalid_token') return res.status(401).json({ error: 'Invalid or expired token' });
274
274
  if (!allowed.includes(user.entity)) return res.status(403).json({ error: 'Access denied' });
@@ -277,7 +277,7 @@ function policyMiddleware(rule, entity, core) {
277
277
  try {
278
278
  // Ownership-based access: condition: self
279
279
  if (p.condition === 'self') {
280
- enforceSelfCondition(rule, entity, req, core);
280
+ await enforceSelfCondition(rule, entity, req, core);
281
281
  }
282
282
  next();
283
283
  } catch (e) {
@@ -328,7 +328,7 @@ function _apiKeyPermGuard(operation, entity) {
328
328
  * - update: ensure the record belongs to the user, disallow ownership change
329
329
  * - delete: ensure the record belongs to the user
330
330
  */
331
- function enforceSelfCondition(rule, entity, req, core) {
331
+ async function enforceSelfCondition(rule, entity, req, core) {
332
332
  const userId = req.user.id;
333
333
  const userEntity = req.user.entity;
334
334
 
@@ -351,7 +351,7 @@ function enforceSelfCondition(rule, entity, req, core) {
351
351
  } else if (rule === 'update' || rule === 'delete') {
352
352
  // Verify the record belongs to the user
353
353
  if (req.params && req.params.id) {
354
- const row = db.findById(entity.tableName, req.params.id);
354
+ const row = await db.findById(entity.tableName, req.params.id);
355
355
  if (row && row[fk] !== userId) {
356
356
  const err = new Error('Access denied: record does not belong to you');
357
357
  err.status = 403;