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.
@@ -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
- | DB_CONNECTION | `sqlite` | Choose `postgres` switching to PostgreSQL or `mysql` for MySQL or MariaDB | All |
46
- | DB_PATH | `/.chadstart/db.sqlite` | Path of the database. Your server should have access to this path locally | SQLite |
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 | `chadstart` | Database name | PostgreSQL / MySQL |
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
- DB_CONNECTION=sqlite
61
+ DB_ENGINE=sqlite
62
62
 
63
- DB_PATH=/.chadstart/db.sqlite
63
+ DB_PATH=/data/chadstart.db
64
64
 
65
65
  ```
66
66
 
67
67
  === "PostgreSQL"
68
68
  ```env
69
69
 
70
- DB_CONNECTION=postgres
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
- DB_CONNECTION=mysql
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=25060
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