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/entity-engine.js
CHANGED
|
@@ -155,6 +155,7 @@ function buildCore(config) {
|
|
|
155
155
|
port: parseInt(process.env.CHADSTART_PORT || process.env.PORT || config.port || 3000, 10),
|
|
156
156
|
rateLimits,
|
|
157
157
|
telemetry,
|
|
158
|
+
oauth: config.oauth || null,
|
|
158
159
|
admin: {
|
|
159
160
|
enable_app: adminCfg.enable_app !== false,
|
|
160
161
|
enable_entity: adminCfg.enable_entity !== false,
|
package/core/oauth.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OAuth / Social Login via the "grant" library.
|
|
5
|
+
*
|
|
6
|
+
* When the YAML config contains an `oauth` section, this module:
|
|
7
|
+
* 1. Mounts the Grant middleware at /connect (handles the redirect dance).
|
|
8
|
+
* 2. Registers a callback route at /api/auth/oauth/callback that
|
|
9
|
+
* finds-or-creates the user and returns a JWT.
|
|
10
|
+
*
|
|
11
|
+
* Secrets (client IDs, client secrets) must be supplied via environment
|
|
12
|
+
* variables — never place them in the YAML file.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const Grant = require('grant').express();
|
|
17
|
+
const rateLimit = require('express-rate-limit');
|
|
18
|
+
const { signToken } = require('./auth');
|
|
19
|
+
const db = require('./db');
|
|
20
|
+
const logger = require('../utils/logger');
|
|
21
|
+
|
|
22
|
+
const oauthLimiter = rateLimit({
|
|
23
|
+
windowMs: 15 * 60 * 1000,
|
|
24
|
+
max: 30,
|
|
25
|
+
standardHeaders: true,
|
|
26
|
+
legacyHeaders: false,
|
|
27
|
+
message: { error: 'Too many OAuth requests, please try again later.' },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ─── Provider profile normalisation ─────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalise the profile returned by Grant into { email, name, providerId }.
|
|
34
|
+
* Different providers return profile data in different shapes.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} provider Lowercase provider key (e.g. "google").
|
|
37
|
+
* @param {object} profile Raw profile object from the OAuth provider.
|
|
38
|
+
* @returns {{ email: string|null, name: string|null, providerId: string|null }}
|
|
39
|
+
*/
|
|
40
|
+
function normalizeProfile(provider, profile) {
|
|
41
|
+
if (!profile) return { email: null, name: null, providerId: null };
|
|
42
|
+
|
|
43
|
+
const email =
|
|
44
|
+
profile.email ||
|
|
45
|
+
profile.mail ||
|
|
46
|
+
(profile.emails && profile.emails[0] && (profile.emails[0].value || profile.emails[0])) ||
|
|
47
|
+
null;
|
|
48
|
+
const name =
|
|
49
|
+
profile.name ||
|
|
50
|
+
profile.displayName ||
|
|
51
|
+
profile.login ||
|
|
52
|
+
profile.username ||
|
|
53
|
+
(profile.first_name ? `${profile.first_name} ${profile.last_name || ''}`.trim() : null) ||
|
|
54
|
+
null;
|
|
55
|
+
const providerId =
|
|
56
|
+
String(profile.sub || profile.id || profile.user_id || profile.account_id || '');
|
|
57
|
+
|
|
58
|
+
return { email: email || null, name: name || null, providerId: providerId || null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Grant config builder ───────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the Grant configuration object from the parsed YAML `oauth` section.
|
|
65
|
+
*
|
|
66
|
+
* Environment variable overrides (per-provider):
|
|
67
|
+
* OAUTH_<PROVIDER>_KEY – client/app ID
|
|
68
|
+
* OAUTH_<PROVIDER>_SECRET – client/app secret
|
|
69
|
+
*
|
|
70
|
+
* @param {object} oauthConfig The `oauth` block from the YAML config.
|
|
71
|
+
* @param {string} baseUrl The application base URL (e.g. http://localhost:3000).
|
|
72
|
+
* @returns {object} Configuration object accepted by Grant.
|
|
73
|
+
*/
|
|
74
|
+
function buildGrantConfig(oauthConfig, baseUrl) {
|
|
75
|
+
const defaults = oauthConfig.defaults || {};
|
|
76
|
+
const origin = baseUrl.replace(/\/$/, '');
|
|
77
|
+
|
|
78
|
+
const grantConfig = {
|
|
79
|
+
defaults: {
|
|
80
|
+
origin,
|
|
81
|
+
transport: 'querystring',
|
|
82
|
+
prefix: '/connect',
|
|
83
|
+
...defaults,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const providers = oauthConfig.providers || {};
|
|
88
|
+
for (const [name, cfg] of Object.entries(providers)) {
|
|
89
|
+
const envPrefix = `OAUTH_${name.toUpperCase()}_`;
|
|
90
|
+
const key = process.env[`${envPrefix}KEY`] || cfg.key || '';
|
|
91
|
+
const secret = process.env[`${envPrefix}SECRET`] || cfg.secret || '';
|
|
92
|
+
|
|
93
|
+
grantConfig[name] = {
|
|
94
|
+
...cfg,
|
|
95
|
+
key,
|
|
96
|
+
secret,
|
|
97
|
+
// callback is where Grant redirects after the OAuth dance;
|
|
98
|
+
// we point it at our own API endpoint which issues a JWT.
|
|
99
|
+
callback: cfg.callback || `/api/auth/oauth/callback`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return grantConfig;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Route registration ─────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Mount the Grant middleware and register the OAuth callback route.
|
|
110
|
+
*
|
|
111
|
+
* @param {import('express').Application} app
|
|
112
|
+
* @param {object} core Parsed core configuration.
|
|
113
|
+
* @param {Function} emit EventBus emit function.
|
|
114
|
+
*/
|
|
115
|
+
function registerOAuthRoutes(app, core, emit) {
|
|
116
|
+
const oauthConfig = core.oauth;
|
|
117
|
+
if (!oauthConfig || !oauthConfig.providers || Object.keys(oauthConfig.providers).length === 0) {
|
|
118
|
+
return; // OAuth not configured — skip
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const baseUrl = (
|
|
122
|
+
process.env.BASE_URL ||
|
|
123
|
+
`http://localhost:${core.port}`
|
|
124
|
+
).replace(/\/$/, '');
|
|
125
|
+
|
|
126
|
+
const grantConfig = buildGrantConfig(oauthConfig, baseUrl);
|
|
127
|
+
|
|
128
|
+
// Mount grant middleware — handles /connect/:provider and /connect/:provider/callback
|
|
129
|
+
app.use(Grant(grantConfig));
|
|
130
|
+
logger.info(' Mounted OAuth middleware at /connect');
|
|
131
|
+
|
|
132
|
+
// Determine the target authenticable entity for OAuth users.
|
|
133
|
+
// The `oauth.entity` field specifies which entity to use (default: first authenticable entity).
|
|
134
|
+
const targetEntityName = oauthConfig.entity || null;
|
|
135
|
+
const entity =
|
|
136
|
+
(targetEntityName && core.authenticableEntities[targetEntityName]) ||
|
|
137
|
+
Object.values(core.authenticableEntities)[0];
|
|
138
|
+
|
|
139
|
+
if (!entity) {
|
|
140
|
+
logger.warn(' OAuth: no authenticable entity found — OAuth callback will not work.');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const _emit = typeof emit === 'function' ? emit : () => {};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* GET /api/auth/oauth/callback
|
|
148
|
+
*
|
|
149
|
+
* Grant redirects here with query-string parameters after the OAuth dance.
|
|
150
|
+
* We extract the access_token / profile, find-or-create the user, and
|
|
151
|
+
* return a JWT (or redirect if `oauth.successRedirect` is set).
|
|
152
|
+
*/
|
|
153
|
+
app.get('/api/auth/oauth/callback', oauthLimiter, async (req, res) => {
|
|
154
|
+
try {
|
|
155
|
+
const { access_token, profile, provider, error } = req.query;
|
|
156
|
+
|
|
157
|
+
if (error) {
|
|
158
|
+
return _handleError(res, oauthConfig, `OAuth error: ${error}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!access_token && !profile) {
|
|
162
|
+
return _handleError(res, oauthConfig, 'OAuth callback missing token and profile');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Grant may pass the profile as a JSON string in the querystring
|
|
166
|
+
let parsedProfile = {};
|
|
167
|
+
if (profile) {
|
|
168
|
+
try { parsedProfile = typeof profile === 'string' ? JSON.parse(profile) : profile; } catch { /* ignore */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const providerName = (provider || 'unknown').toLowerCase();
|
|
172
|
+
const { email, name, providerId } = normalizeProfile(providerName, parsedProfile);
|
|
173
|
+
|
|
174
|
+
if (!email && !providerId) {
|
|
175
|
+
return _handleError(res, oauthConfig, 'Could not obtain email or provider ID from OAuth profile');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Find or create the user
|
|
179
|
+
const user = await _findOrCreateOAuthUser(entity, {
|
|
180
|
+
email,
|
|
181
|
+
name,
|
|
182
|
+
provider: providerName,
|
|
183
|
+
providerId,
|
|
184
|
+
accessToken: access_token || null,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
_emit(`${entity.name}.oauthLogin`, { provider: providerName, userId: user.id });
|
|
188
|
+
|
|
189
|
+
const token = signToken({ id: user.id, entity: entity.name });
|
|
190
|
+
|
|
191
|
+
// Redirect or return JSON based on config
|
|
192
|
+
const successRedirect = oauthConfig.successRedirect;
|
|
193
|
+
if (successRedirect) {
|
|
194
|
+
const sep = successRedirect.includes('?') ? '&' : '?';
|
|
195
|
+
return res.redirect(`${successRedirect}${sep}token=${encodeURIComponent(token)}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
res.json({ token, user: _omitSensitive(user) });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
logger.error('OAuth callback error:', e.message);
|
|
201
|
+
return _handleError(res, oauthConfig, e.message);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// List configured providers
|
|
206
|
+
app.get('/api/auth/oauth/providers', oauthLimiter, (_req, res) => {
|
|
207
|
+
const providers = Object.keys(oauthConfig.providers || {});
|
|
208
|
+
res.json({ providers });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const providerNames = Object.keys(oauthConfig.providers);
|
|
212
|
+
logger.info(` OAuth providers: ${providerNames.join(', ')}`);
|
|
213
|
+
logger.info(` OAuth callback: /api/auth/oauth/callback`);
|
|
214
|
+
logger.info(` OAuth entity: ${entity.name}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Find an existing user by email or create a new one.
|
|
221
|
+
*/
|
|
222
|
+
async function _findOrCreateOAuthUser(entity, { email, name, provider, providerId, accessToken }) {
|
|
223
|
+
const table = entity.tableName;
|
|
224
|
+
|
|
225
|
+
// 1. Try to find by email
|
|
226
|
+
if (email) {
|
|
227
|
+
const existing = await db.findAllSimple(table, { email });
|
|
228
|
+
if (existing.length > 0) return existing[0];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 2. Create a new user with a random password (they authenticate via OAuth)
|
|
232
|
+
const newUser = {
|
|
233
|
+
email: email || `${provider}_${providerId}@oauth.local`,
|
|
234
|
+
password: crypto.randomBytes(32).toString('hex'), // random placeholder
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Add optional fields if the entity supports them
|
|
238
|
+
const propNames = new Set(entity.properties.map((p) => p.name));
|
|
239
|
+
if (name && propNames.has('name')) newUser.name = name;
|
|
240
|
+
|
|
241
|
+
const bcrypt = require('bcryptjs');
|
|
242
|
+
newUser.password = await bcrypt.hash(newUser.password, 10);
|
|
243
|
+
|
|
244
|
+
const created = await db.create(table, newUser);
|
|
245
|
+
return created;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _omitSensitive(user) {
|
|
249
|
+
if (!user) return null;
|
|
250
|
+
const { password: _, ...rest } = user;
|
|
251
|
+
return rest;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _handleError(res, oauthConfig, message) {
|
|
255
|
+
const errorRedirect = oauthConfig.errorRedirect;
|
|
256
|
+
if (errorRedirect) {
|
|
257
|
+
const sep = errorRedirect.includes('?') ? '&' : '?';
|
|
258
|
+
return res.redirect(`${errorRedirect}${sep}error=${encodeURIComponent(message)}`);
|
|
259
|
+
}
|
|
260
|
+
return res.status(400).json({ error: message });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = { registerOAuthRoutes, buildGrantConfig, normalizeProfile };
|
package/core/seeder.js
CHANGED
|
@@ -192,7 +192,7 @@ async function seedAll(core) {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
try {
|
|
195
|
-
const created = create(entity.tableName, record);
|
|
195
|
+
const created = await create(entity.tableName, record);
|
|
196
196
|
ids.push(created.id);
|
|
197
197
|
} catch (err) {
|
|
198
198
|
logger.warn(`Seed: failed to create record for ${entityName}:`, err.message);
|
|
@@ -206,7 +206,7 @@ async function seedAll(core) {
|
|
|
206
206
|
// Create the admin@chadstart.com user in every authenticable entity
|
|
207
207
|
const adminUsers = [];
|
|
208
208
|
for (const entity of Object.values(core.authenticableEntities || {})) {
|
|
209
|
-
const existing = findAllSimple(entity.tableName, { email: ADMIN_EMAIL });
|
|
209
|
+
const existing = await findAllSimple(entity.tableName, { email: ADMIN_EMAIL });
|
|
210
210
|
if (existing.length === 0) {
|
|
211
211
|
const extraProps = entity.properties.reduce((acc, prop) => {
|
|
212
212
|
// Skip email and password — they are handled separately for authenticable entities
|
|
@@ -216,7 +216,7 @@ async function seedAll(core) {
|
|
|
216
216
|
}
|
|
217
217
|
return acc;
|
|
218
218
|
}, {});
|
|
219
|
-
create(entity.tableName, {
|
|
219
|
+
await create(entity.tableName, {
|
|
220
220
|
email: ADMIN_EMAIL,
|
|
221
221
|
password: bcrypt.hashSync(ADMIN_PASSWORD, 10),
|
|
222
222
|
...extraProps,
|
package/docs/auth.md
CHANGED
|
@@ -42,6 +42,9 @@ entities:
|
|
|
42
42
|
|
|
43
43
|
Authenticable entities have 2 extra properties that are used as credentials to log in: `email` and `password`. You do not need to specify them.The `email` property expects a unique valid emails and the `password` property is automatically hashed using _bcryt_ with 10 salt rounds.
|
|
44
44
|
|
|
45
|
+
!!! tip "Social Login"
|
|
46
|
+
Want users to log in with Google, GitHub, Discord, or other OAuth providers? See the [OAuth / Social Login](./oauth.md) guide.
|
|
47
|
+
|
|
45
48
|
## Actions
|
|
46
49
|
|
|
47
50
|
### Login
|
package/docs/config.md
CHANGED
|
@@ -42,13 +42,13 @@ We recommend switching to [PostgreSQL](https://www.postgresql.org/) or [MySQL](h
|
|
|
42
42
|
|
|
43
43
|
| Variable | Default | Description | Applies To |
|
|
44
44
|
| ------------- | ---------------------- | ------------------------------------------------------------------------- | ------------------ |
|
|
45
|
-
|
|
|
46
|
-
| DB_PATH |
|
|
45
|
+
| DB_ENGINE | `sqlite` | Choose `postgres` switching to PostgreSQL or `mysql` for MySQL or MariaDB | All |
|
|
46
|
+
| DB_PATH | `/data/chadstart.db` | Path of the database. Your server should have access to this path locally | SQLite |
|
|
47
47
|
| DB_HOST | `localhost` | Database host | PostgreSQL / MySQL |
|
|
48
48
|
| DB_PORT | `5432` | Database port | PostgreSQL / MySQL |
|
|
49
49
|
| DB_USERNAME | `postgres` | Database username | PostgreSQL / MySQL |
|
|
50
50
|
| DB_PASSWORD | `postgres` | Database password | PostgreSQL / MySQL |
|
|
51
|
-
| DB_DATABASE | `
|
|
51
|
+
| DB_DATABASE | `manifest` | Database name | PostgreSQL / MySQL |
|
|
52
52
|
| DB_SSL | `false` | Require SSL for DB connection. Set to true if using remote DB. | PostgreSQL / MySQL |
|
|
53
53
|
|
|
54
54
|
### Example configurations
|
|
@@ -58,16 +58,16 @@ Here are examples of `.env` files for different database connections:
|
|
|
58
58
|
=== "SQLite"
|
|
59
59
|
```env
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
DB_ENGINE=sqlite
|
|
62
62
|
|
|
63
|
-
DB_PATH
|
|
63
|
+
DB_PATH=/data/chadstart.db
|
|
64
64
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
=== "PostgreSQL"
|
|
68
68
|
```env
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
DB_ENGINE=postgres
|
|
71
71
|
|
|
72
72
|
DB_HOST=my-host.com
|
|
73
73
|
DB_USERNAME=owner
|
|
@@ -80,12 +80,12 @@ Here are examples of `.env` files for different database connections:
|
|
|
80
80
|
=== "MySQL / MariaDB"
|
|
81
81
|
```env
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
DB_ENGINE=mysql
|
|
84
84
|
|
|
85
85
|
DB_USERNAME=xxxxx
|
|
86
86
|
DB_PASSWORD=xxxxx
|
|
87
87
|
DB_HOST=my-host.com
|
|
88
|
-
DB_PORT=
|
|
88
|
+
DB_PORT=3306
|
|
89
89
|
DB_DATABASE=my_app
|
|
90
90
|
DB_SSL=true # Required for remote managed DBs, remove if local
|
|
91
91
|
|