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/.devcontainer/devcontainer.json +34 -0
- package/.env.example +30 -1
- package/.github/workflows/db-integration.yml +139 -0
- package/.github/workflows/npm-chadstart.yml +12 -1
- package/.github/workflows/npm-sdk.yml +12 -1
- package/chadstart.example.yml +52 -0
- package/chadstart.schema.json +62 -0
- package/core/api-generator.js +36 -36
- package/core/auth.js +76 -65
- package/core/db.js +324 -149
- package/core/entity-engine.js +1 -0
- package/core/oauth.js +263 -0
- package/core/seeder.js +3 -3
- package/docs/auth.md +3 -0
- package/docs/config.md +8 -8
- package/docs/oauth.md +869 -0
- package/mkdocs.yml +1 -0
- package/package.json +5 -1
- package/server/express-server.js +20 -18
- package/test/access-policies.test.js +8 -8
- package/test/api-keys.test.js +28 -28
- package/test/auth.test.js +18 -18
- package/test/db.test.js +71 -71
- package/test/groups.test.js +5 -5
- package/test/integration/db-integration.test.js +368 -0
- package/test/middleware.test.js +1 -1
- package/test/oauth.test.js +259 -0
- package/test/sdk.test.js +19 -19
- package/test/seeder.test.js +26 -26
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.
|
|
45
|
-
CREATE TABLE IF NOT EXISTS
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
80
|
-
`INSERT INTO
|
|
81
|
-
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
82
|
-
|
|
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.
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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;
|