chadstart 1.0.0 → 1.0.2

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/core/auth.js CHANGED
@@ -29,6 +29,17 @@ const JWT_SECRET = process.env.JWT_SECRET || process.env.TOKEN_SECRET_KEY || (()
29
29
  const JWT_EXPIRES = process.env.JWT_EXPIRES || '7d';
30
30
  const BCRYPT_ROUNDS = 10;
31
31
 
32
+ // Quote an identifier for the current database engine (mirrors db.js helper)
33
+ const _DB_ENGINE = (process.env.DB_ENGINE || 'sqlite').toLowerCase();
34
+ function _q(name) { return _DB_ENGINE === 'mysql' ? `\`${name}\`` : `"${name}"`; }
35
+
36
+ // Column types for the API keys table (must be indexable in all engines)
37
+ const _ID_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(36)' : 'TEXT';
38
+ const _HASH_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(64)' : 'TEXT';
39
+ const _NAME_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(255)' : 'TEXT';
40
+ // JSON array columns — MySQL forbids DEFAULT on TEXT, so use bounded VARCHAR
41
+ const _JSON_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(2000)' : 'TEXT';
42
+
32
43
  function signToken(payload, expiresIn) {
33
44
  return jwt.sign(payload, JWT_SECRET, { expiresIn: expiresIn !== undefined ? expiresIn : JWT_EXPIRES });
34
45
  }
@@ -40,20 +51,20 @@ function verifyToken(token) { return jwt.verify(token, JWT_SECRET); }
40
51
  * Initialize the _cs_api_keys system table.
41
52
  * Must be called after initDb().
42
53
  */
43
- function initApiKeys() {
44
- db.getDb().exec(`
45
- CREATE TABLE IF NOT EXISTS "_cs_api_keys" (
46
- "id" TEXT PRIMARY KEY,
47
- "name" TEXT NOT NULL,
48
- "keyHash" TEXT NOT NULL UNIQUE,
49
- "userId" TEXT NOT NULL,
50
- "userEntity" TEXT NOT NULL,
51
- "permissions" TEXT NOT NULL DEFAULT '[]',
52
- "entities" TEXT NOT NULL DEFAULT '[]',
53
- "expiresAt" TEXT,
54
- "createdAt" TEXT NOT NULL,
55
- "updatedAt" TEXT NOT NULL,
56
- "lastUsedAt" TEXT
54
+ async function initApiKeys() {
55
+ await db.exec(`
56
+ CREATE TABLE IF NOT EXISTS ${_q('_cs_api_keys')} (
57
+ ${_q('id')} ${_ID_T} PRIMARY KEY,
58
+ ${_q('name')} ${_NAME_T} NOT NULL,
59
+ ${_q('keyHash')} ${_HASH_T} NOT NULL UNIQUE,
60
+ ${_q('userId')} ${_ID_T} NOT NULL,
61
+ ${_q('userEntity')} ${_NAME_T} NOT NULL,
62
+ ${_q('permissions')} ${_JSON_T} NOT NULL DEFAULT '[]',
63
+ ${_q('entities')} ${_JSON_T} NOT NULL DEFAULT '[]',
64
+ ${_q('expiresAt')} TEXT,
65
+ ${_q('createdAt')} TEXT NOT NULL,
66
+ ${_q('updatedAt')} TEXT NOT NULL,
67
+ ${_q('lastUsedAt')} TEXT
57
68
  )
58
69
  `);
59
70
  }
@@ -69,69 +80,72 @@ function _hashApiKey(key) {
69
80
  * @param {object} opts { name, permissions, entities, expiresAt }
70
81
  * @returns {{ key: string, record: object }} key is the plaintext — returned once only.
71
82
  */
72
- function createApiKey(userId, userEntity, opts = {}) {
83
+ async function createApiKey(userId, userEntity, opts = {}) {
73
84
  const { name = 'API Key', permissions = [], entities = [], expiresAt = null } = opts;
74
85
  const key = API_KEY_PREFIX + crypto.randomBytes(32).toString('hex');
75
86
  const keyHash = _hashApiKey(key);
76
87
  const now = new Date().toISOString();
77
88
  const id = crypto.randomUUID();
78
89
 
79
- db.getDb().prepare(
80
- `INSERT INTO "_cs_api_keys" ("id","name","keyHash","userId","userEntity","permissions","entities","expiresAt","createdAt","updatedAt")
81
- VALUES (?,?,?,?,?,?,?,?,?,?)`
82
- ).run(
83
- id, name, keyHash, userId, userEntity,
84
- JSON.stringify(permissions), JSON.stringify(entities),
85
- expiresAt || null, now, now
90
+ await db.queryRun(
91
+ `INSERT INTO ${_q('_cs_api_keys')} (${_q('id')},${_q('name')},${_q('keyHash')},${_q('userId')},${_q('userEntity')},${_q('permissions')},${_q('entities')},${_q('expiresAt')},${_q('createdAt')},${_q('updatedAt')})
92
+ VALUES (?,?,?,?,?,?,?,?,?,?)`,
93
+ [id, name, keyHash, userId, userEntity, JSON.stringify(permissions), JSON.stringify(entities), expiresAt || null, now, now]
86
94
  );
87
95
 
88
- const record = db.getDb().prepare('SELECT * FROM "_cs_api_keys" WHERE "id" = ?').get(id);
89
- const safe = _safeApiKeyRecord(record);
90
- return { key, record: safe };
96
+ const record = await db.queryOne(
97
+ `SELECT * FROM ${_q('_cs_api_keys')} WHERE ${_q('id')} = ?`, [id]
98
+ );
99
+ return { key, record: _safeApiKeyRecord(record) };
91
100
  }
92
101
 
93
102
  /**
94
103
  * Verify an API key string. Returns the DB record (without keyHash) or null.
95
104
  */
96
- function verifyApiKeyStr(key) {
105
+ async function verifyApiKeyStr(key) {
97
106
  if (!key || !key.startsWith(API_KEY_PREFIX)) return null;
98
107
  const hash = _hashApiKey(key);
99
- const record = db.getDb().prepare('SELECT * FROM "_cs_api_keys" WHERE "keyHash" = ?').get(hash);
108
+ const record = await db.queryOne(
109
+ `SELECT * FROM ${_q('_cs_api_keys')} WHERE ${_q('keyHash')} = ?`, [hash]
110
+ );
100
111
  if (!record) return null;
101
112
  if (record.expiresAt && new Date(record.expiresAt) < new Date()) return null;
102
113
  // Update lastUsedAt asynchronously (best effort)
103
114
  try {
104
- db.getDb().prepare('UPDATE "_cs_api_keys" SET "lastUsedAt" = ? WHERE "id" = ?').run(new Date().toISOString(), record.id);
115
+ await db.queryRun(
116
+ `UPDATE ${_q('_cs_api_keys')} SET ${_q('lastUsedAt')} = ? WHERE ${_q('id')} = ?`,
117
+ [new Date().toISOString(), record.id]
118
+ );
105
119
  } catch { /* ignore */ }
106
120
  return _safeApiKeyRecord(record);
107
121
  }
108
122
 
109
123
  /** List API keys for a specific user (without key hashes). */
110
- function listApiKeys(userId, userEntity) {
111
- return db.getDb()
112
- .prepare('SELECT * FROM "_cs_api_keys" WHERE "userId" = ? AND "userEntity" = ? ORDER BY "createdAt" DESC')
113
- .all(userId, userEntity)
114
- .map(_safeApiKeyRecord);
124
+ async function listApiKeys(userId, userEntity) {
125
+ const rows = await db.queryAll(
126
+ `SELECT * FROM ${_q('_cs_api_keys')} WHERE ${_q('userId')} = ? AND ${_q('userEntity')} = ? ORDER BY ${_q('createdAt')} DESC`,
127
+ [userId, userEntity]
128
+ );
129
+ return rows.map(_safeApiKeyRecord);
115
130
  }
116
131
 
117
132
  /** List all API keys — admin view (without key hashes). */
118
- function listAllApiKeys() {
119
- return db.getDb()
120
- .prepare('SELECT * FROM "_cs_api_keys" ORDER BY "createdAt" DESC')
121
- .all()
122
- .map(_safeApiKeyRecord);
133
+ async function listAllApiKeys() {
134
+ const rows = await db.queryAll(
135
+ `SELECT * FROM ${_q('_cs_api_keys')} ORDER BY ${_q('createdAt')} DESC`, []
136
+ );
137
+ return rows.map(_safeApiKeyRecord);
123
138
  }
124
139
 
125
140
  /** Delete an API key by ID. */
126
- function deleteApiKey(id) {
127
- db.getDb().prepare('DELETE FROM "_cs_api_keys" WHERE "id" = ?').run(id);
141
+ async function deleteApiKey(id) {
142
+ await db.queryRun(`DELETE FROM ${_q('_cs_api_keys')} WHERE ${_q('id')} = ?`, [id]);
128
143
  }
129
144
 
130
145
  /** Strip the keyHash before returning to clients. */
131
146
  function _safeApiKeyRecord(record) {
132
147
  if (!record) return null;
133
148
  const { keyHash: _, ...safe } = record;
134
- // Parse JSON arrays
135
149
  if (typeof safe.permissions === 'string') {
136
150
  try { safe.permissions = JSON.parse(safe.permissions); } catch { safe.permissions = []; }
137
151
  }
@@ -143,10 +157,10 @@ function _safeApiKeyRecord(record) {
143
157
 
144
158
  /**
145
159
  * Resolve an Authorization header to a user payload.
146
- * Supports both JWT Bearer tokens and API key strings (cs_ prefix).
160
+ * Supports both JWT Bearer and API key strings (cs_ prefix).
147
161
  * Returns { user, apiKeyPermissions, error }.
148
162
  */
149
- function resolveAuthHeader(header) {
163
+ async function resolveAuthHeader(header) {
150
164
  if (!header || !header.startsWith('Bearer ')) {
151
165
  return { user: null, apiKeyPermissions: null, error: 'no_header' };
152
166
  }
@@ -154,7 +168,7 @@ function resolveAuthHeader(header) {
154
168
 
155
169
  // API key
156
170
  if (token.startsWith(API_KEY_PREFIX)) {
157
- const record = verifyApiKeyStr(token);
171
+ const record = await verifyApiKeyStr(token);
158
172
  if (!record) return { user: null, apiKeyPermissions: null, error: 'invalid_token' };
159
173
  return {
160
174
  user: { id: record.userId, entity: record.userEntity },
@@ -174,27 +188,24 @@ function resolveAuthHeader(header) {
174
188
 
175
189
  /**
176
190
  * Register API key management routes for each authenticable entity.
177
- * GET /api/auth/:slug/api-keys — list caller's keys
178
- * POST /api/auth/:slug/api-keys — create a new key
179
- * DELETE /api/auth/:slug/api-keys/:id — delete a key
180
191
  */
181
192
  function registerApiKeyRoutes(app, core) {
182
193
  for (const entity of Object.values(core.authenticableEntities || {})) {
183
194
  const slug = entity.slug;
184
195
 
185
196
  // List caller's API keys
186
- app.get(`/api/auth/${slug}/api-keys`, requireAuth(entity.name), (req, res) => {
197
+ app.get(`/api/auth/${slug}/api-keys`, requireAuth(entity.name), async (req, res) => {
187
198
  try {
188
- const keys = listApiKeys(req.user.id, entity.name);
199
+ const keys = await listApiKeys(req.user.id, entity.name);
189
200
  res.json(keys);
190
201
  } catch (e) { res.status(500).json({ error: e.message }); }
191
202
  });
192
203
 
193
204
  // Create a new API key
194
- app.post(`/api/auth/${slug}/api-keys`, requireAuth(entity.name), (req, res) => {
205
+ app.post(`/api/auth/${slug}/api-keys`, requireAuth(entity.name), async (req, res) => {
195
206
  try {
196
207
  const { name, permissions, entities: keyEntities, expiresAt } = req.body || {};
197
- const { key, record } = createApiKey(req.user.id, entity.name, {
208
+ const { key, record } = await createApiKey(req.user.id, entity.name, {
198
209
  name: name || 'API Key',
199
210
  permissions: Array.isArray(permissions) ? permissions : [],
200
211
  entities: Array.isArray(keyEntities) ? keyEntities : [],
@@ -205,14 +216,16 @@ function registerApiKeyRoutes(app, core) {
205
216
  });
206
217
 
207
218
  // Delete one of the caller's API keys
208
- app.delete(`/api/auth/${slug}/api-keys/:id`, requireAuth(entity.name), (req, res) => {
219
+ app.delete(`/api/auth/${slug}/api-keys/:id`, requireAuth(entity.name), async (req, res) => {
209
220
  try {
210
- const record = db.getDb().prepare('SELECT * FROM "_cs_api_keys" WHERE "id" = ?').get(req.params.id);
221
+ const record = await db.queryOne(
222
+ `SELECT * FROM ${_q('_cs_api_keys')} WHERE ${_q('id')} = ?`, [req.params.id]
223
+ );
211
224
  if (!record) return res.status(404).json({ error: 'API key not found' });
212
225
  if (record.userId !== req.user.id || record.userEntity !== entity.name) {
213
226
  return res.status(403).json({ error: 'Access denied' });
214
227
  }
215
- deleteApiKey(req.params.id);
228
+ await deleteApiKey(req.params.id);
216
229
  res.json({ success: true });
217
230
  } catch (e) { res.status(500).json({ error: e.message }); }
218
231
  });
@@ -227,18 +240,16 @@ function registerAuthRoutes(app, core, emit) {
227
240
  const allowed = new Set(entity.properties.map((p) => p.name));
228
241
  const sanitize = (body) => Object.fromEntries(Object.entries(body).filter(([k]) => allowed.has(k)));
229
242
 
230
- // Check signup policy: forbidden => block, other values => allow
231
243
  const signupPolicies = (entity.policies || {}).signup;
232
244
  const signupForbidden = signupPolicies && signupPolicies.length > 0 && signupPolicies[0].access === 'forbidden';
233
245
 
234
246
  app.post(`/api/auth/${slug}/signup`, async (req, res) => {
235
247
  try {
236
248
  if (signupForbidden) return res.status(403).json({ error: 'Signup is forbidden for this entity' });
237
-
238
249
  const { email, password, ...rest } = req.body || {};
239
250
  if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
240
- if (db.findAllSimple(table, { email }).length) return res.status(409).json({ error: 'Email already registered' });
241
- const user = db.create(table, { email, password: await bcrypt.hash(password, BCRYPT_ROUNDS), ...sanitize(rest) });
251
+ if ((await db.findAllSimple(table, { email })).length) return res.status(409).json({ error: 'Email already registered' });
252
+ const user = await db.create(table, { email, password: await bcrypt.hash(password, BCRYPT_ROUNDS), ...sanitize(rest) });
242
253
  _emit(`${entity.name}.created`, omitPassword(user));
243
254
  res.status(201).json({ token: signToken({ id: user.id, entity: entity.name }), user: omitPassword(user) });
244
255
  } catch (e) { logger.error('signup error', e.message); res.status(500).json({ error: e.message }); }
@@ -248,14 +259,14 @@ function registerAuthRoutes(app, core, emit) {
248
259
  try {
249
260
  const { email, password } = req.body || {};
250
261
  if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
251
- const user = db.findAllSimple(table, { email })[0];
262
+ const user = (await db.findAllSimple(table, { email }))[0];
252
263
  if (!user || !(await bcrypt.compare(password, user.password))) return res.status(401).json({ error: 'Invalid credentials' });
253
264
  res.json({ token: signToken({ id: user.id, entity: entity.name }), user: omitPassword(user) });
254
265
  } catch (e) { logger.error('login error', e.message); res.status(500).json({ error: e.message }); }
255
266
  });
256
267
 
257
- app.get(`/api/auth/${slug}/me`, requireAuth(entity.name), (req, res) => {
258
- const user = db.findById(table, req.user.id);
268
+ app.get(`/api/auth/${slug}/me`, requireAuth(entity.name), async (req, res) => {
269
+ const user = await db.findById(table, req.user.id);
259
270
  if (!user) return res.status(404).json({ error: 'User not found' });
260
271
  res.json(omitPassword(user));
261
272
  });
@@ -265,8 +276,8 @@ function registerAuthRoutes(app, core, emit) {
265
276
  }
266
277
 
267
278
  function requireAuth(entityName) {
268
- return (req, res, next) => {
269
- const { user, apiKeyPermissions, error } = resolveAuthHeader(req.headers.authorization);
279
+ return async (req, res, next) => {
280
+ const { user, apiKeyPermissions, error } = await resolveAuthHeader(req.headers.authorization);
270
281
  if (!user) return res.status(401).json({ error: 'Authorization header required (Bearer <token>)' });
271
282
  if (error === 'invalid_token') return res.status(401).json({ error: 'Invalid or expired token' });
272
283
  if (entityName && user.entity !== entityName) return res.status(403).json({ error: 'Token does not belong to this collection' });
@@ -276,8 +287,8 @@ function requireAuth(entityName) {
276
287
  };
277
288
  }
278
289
 
279
- function optionalAuth(req, _res, next) {
280
- const { user, apiKeyPermissions } = resolveAuthHeader(req.headers.authorization);
290
+ async function optionalAuth(req, _res, next) {
291
+ const { user, apiKeyPermissions } = await resolveAuthHeader(req.headers.authorization);
281
292
  if (user) {
282
293
  req.user = user;
283
294
  if (apiKeyPermissions) req._apiKeyPermissions = apiKeyPermissions;