@yunsoft/yuncms-core 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-core",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Core services, schema, authentication, permissions and storage primitives for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/bootstrap.js CHANGED
@@ -17,6 +17,8 @@ import { aiSettingsMigration } from './migrations/0014-ai-settings.js';
17
17
  import { navigationGroupsMigration } from './migrations/0015-navigation-groups.js';
18
18
  import { navigationGroupCollapseMigration } from './migrations/0016-navigation-group-collapse.js';
19
19
  import { mcpSettingsMigration } from './migrations/0017-mcp-settings.js';
20
+ import { publicRegistrationSettingsMigration } from './migrations/0018-public-registration-settings.js';
21
+ import { publicRegistrationEmailVerificationMigration } from './migrations/0019-public-registration-email-verification.js';
20
22
  import { readSchemaVersion } from './schema-version.js';
21
23
  import { ensurePublicRole } from './setup.js';
22
24
 
@@ -38,6 +40,8 @@ export const CORE_MIGRATIONS = Object.freeze([
38
40
  navigationGroupsMigration,
39
41
  navigationGroupCollapseMigration,
40
42
  mcpSettingsMigration,
43
+ publicRegistrationSettingsMigration,
44
+ publicRegistrationEmailVerificationMigration,
41
45
  ]);
42
46
 
43
47
  export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
@@ -0,0 +1,10 @@
1
+ export const publicRegistrationSettingsMigration = {
2
+ id: '0018-public-registration-settings',
3
+ statements: [
4
+ `ALTER TABLE yuncms_studio_settings
5
+ ADD COLUMN public_registration_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER default_locale,
6
+ ADD COLUMN public_registration_role CHAR(36) NULL AFTER public_registration_enabled,
7
+ ADD CONSTRAINT fk_yuncms_studio_settings_public_registration_role
8
+ FOREIGN KEY (public_registration_role) REFERENCES yuncms_roles(id) ON DELETE SET NULL`,
9
+ ],
10
+ };
@@ -0,0 +1,8 @@
1
+ export const publicRegistrationEmailVerificationMigration = {
2
+ id: '0019-public-registration-email-verification',
3
+ statements: [
4
+ `ALTER TABLE yuncms_studio_settings
5
+ ADD COLUMN public_registration_require_email_verification TINYINT(1) NOT NULL DEFAULT 0
6
+ AFTER public_registration_role`,
7
+ ],
8
+ };
@@ -13,6 +13,12 @@ function invalidLogin() {
13
13
  return error;
14
14
  }
15
15
 
16
+ function emailNotVerified() {
17
+ const error = new Error('Email verification is required before sign in');
18
+ error.code = 'EMAIL_NOT_VERIFIED';
19
+ return error;
20
+ }
21
+
16
22
  function invalidToken() {
17
23
  const error = new Error('Invalid or expired authentication token');
18
24
  error.code = 'INVALID_CREDENTIALS';
@@ -95,6 +101,23 @@ export class AuthService extends BaseService {
95
101
  throw invalidLogin();
96
102
  }
97
103
 
104
+ if (!user.email_verified_at) {
105
+ const [settingsRows] = await this.database.query(
106
+ `SELECT public_registration_require_email_verification
107
+ FROM yuncms_studio_settings
108
+ WHERE id = 1
109
+ LIMIT 1`,
110
+ );
111
+ if (settingsRows[0]?.public_registration_require_email_verification) {
112
+ await this.action('auth.login.failed', {
113
+ method: 'local',
114
+ reason: 'email_not_verified',
115
+ user: user.id,
116
+ }, { ip });
117
+ throw emailNotVerified();
118
+ }
119
+ }
120
+
98
121
  const tokens = await this.createSessionsService().createForUser(user, { ip, userAgent });
99
122
  await this.action('auth.login.success', {
100
123
  method: 'local',
@@ -30,6 +30,13 @@ function assertPositiveTtl(ttlMs) {
30
30
  }
31
31
  }
32
32
 
33
+ function assertVerificationManager(accountability) {
34
+ if (accountability.system === true || accountability.admin === true) return;
35
+ const error = new Error('Email verification lookup requires administrator accountability');
36
+ error.code = 'FORBIDDEN';
37
+ throw error;
38
+ }
39
+
33
40
  export class AuthTokensService extends BaseService {
34
41
  async createForUser(userId, type, { ttlMs = DEFAULT_TTL_MS[type] } = {}) {
35
42
  if (!TOKEN_TYPES[type]) {
@@ -162,6 +169,28 @@ export class AuthTokensService extends BaseService {
162
169
  return this.createForUser(userId, 'verify', options);
163
170
  }
164
171
 
172
+ async requestEmailVerification(email, options = {}) {
173
+ assertVerificationManager(this.accountability);
174
+
175
+ let normalized;
176
+ try {
177
+ normalized = normalizeEmail(email);
178
+ } catch {
179
+ return null;
180
+ }
181
+
182
+ const user = await readAuthenticationUserByEmail(this.database, normalized);
183
+ if (!user || user.status !== 'active' || user.email_verified_at) return null;
184
+ const result = await this.createForUser(user.id, 'verify', options);
185
+ return {
186
+ ...result,
187
+ user: {
188
+ id: user.id,
189
+ email: user.email,
190
+ },
191
+ };
192
+ }
193
+
165
194
  async verifyEmail(token) {
166
195
  if (tokenType(token) !== 'verify') throw invalidActionToken();
167
196
  const connection = await this.database.getConnection();
@@ -68,6 +68,18 @@ function normalizeLocale(value) {
68
68
  return normalized;
69
69
  }
70
70
 
71
+ function normalizeBoolean(value, label) {
72
+ if (value === true || value === false) return value;
73
+ throw invalid(`${label} must be a boolean`);
74
+ }
75
+
76
+ function normalizeRegistrationRole(value) {
77
+ if (value == null || value === '') return null;
78
+ const normalized = String(value).trim();
79
+ if (!UUID_PATTERN.test(normalized)) throw invalid('Public registration role id must be a UUID');
80
+ return normalized;
81
+ }
82
+
71
83
  function publicSettings(row) {
72
84
  return {
73
85
  brand_name: row.brand_name,
@@ -77,14 +89,25 @@ function publicSettings(row) {
77
89
  accent_color: row.accent_color,
78
90
  theme: row.theme,
79
91
  default_locale: row.default_locale,
92
+ public_registration_enabled: Boolean(row.public_registration_enabled),
93
+ public_registration_require_email_verification: Boolean(row.public_registration_require_email_verification),
80
94
  updated_at: row.updated_at ?? null,
81
95
  };
82
96
  }
83
97
 
98
+ function managedSettings(row) {
99
+ return {
100
+ ...publicSettings(row),
101
+ public_registration_role: row.public_registration_role ?? null,
102
+ };
103
+ }
104
+
84
105
  export class StudioSettingsService extends BaseService {
85
- async readPublic() {
106
+ async #readSettingsRow() {
86
107
  const [rows] = await this.database.query(
87
- `SELECT brand_name, logo_url, logo_file, favicon_file, accent_color, theme, default_locale, updated_at
108
+ `SELECT brand_name, logo_url, logo_file, favicon_file, accent_color, theme, default_locale,
109
+ public_registration_enabled, public_registration_role,
110
+ public_registration_require_email_verification, updated_at
88
111
  FROM yuncms_studio_settings
89
112
  WHERE id = 1
90
113
  LIMIT 1`,
@@ -95,12 +118,16 @@ export class StudioSettingsService extends BaseService {
95
118
  error.code = 'DATABASE_MIGRATION_REQUIRED';
96
119
  throw error;
97
120
  }
98
- return publicSettings(row);
121
+ return row;
122
+ }
123
+
124
+ async readPublic() {
125
+ return publicSettings(await this.#readSettingsRow());
99
126
  }
100
127
 
101
128
  async readOne() {
102
129
  assertManager(this.accountability);
103
- return this.readPublic();
130
+ return managedSettings(await this.#readSettingsRow());
104
131
  }
105
132
 
106
133
  async readImageAssetContent(settingKey, label) {
@@ -147,16 +174,55 @@ export class StudioSettingsService extends BaseService {
147
174
  }
148
175
  }
149
176
 
177
+ async validateRegistrationRole(roleId) {
178
+ if (!roleId) return;
179
+ const [roles] = await this.database.query(
180
+ 'SELECT id, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
181
+ [roleId],
182
+ );
183
+ const role = roles[0];
184
+ if (!role) throw invalid('Selected public registration role does not exist');
185
+ if (role.admin) throw invalid('Administrator roles cannot be used for public registration');
186
+ if (role.public) throw invalid('The unauthenticated public role cannot be used for registered users');
187
+ }
188
+
150
189
  async updateOne(patch = {}) {
151
190
  assertManager(this.accountability);
152
191
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid('Studio settings patch must be an object');
153
192
 
154
193
  const keys = Object.keys(patch);
155
- const allowed = new Set(['brand_name', 'logo_url', 'logo_file', 'favicon_file', 'accent_color', 'theme', 'default_locale']);
194
+ const allowed = new Set([
195
+ 'brand_name',
196
+ 'logo_url',
197
+ 'logo_file',
198
+ 'favicon_file',
199
+ 'accent_color',
200
+ 'theme',
201
+ 'default_locale',
202
+ 'public_registration_enabled',
203
+ 'public_registration_role',
204
+ 'public_registration_require_email_verification',
205
+ ]);
156
206
  if (keys.length === 0 || keys.some((key) => !allowed.has(key))) {
157
207
  throw invalid('Studio settings patch contains unsupported properties');
158
208
  }
159
209
 
210
+ let normalizedRegistrationEnabled;
211
+ let normalizedRegistrationRole;
212
+ if (Object.hasOwn(patch, 'public_registration_enabled') || Object.hasOwn(patch, 'public_registration_role')) {
213
+ const current = await this.readOne();
214
+ normalizedRegistrationEnabled = Object.hasOwn(patch, 'public_registration_enabled')
215
+ ? normalizeBoolean(patch.public_registration_enabled, 'Public registration enabled')
216
+ : current.public_registration_enabled;
217
+ normalizedRegistrationRole = Object.hasOwn(patch, 'public_registration_role')
218
+ ? normalizeRegistrationRole(patch.public_registration_role)
219
+ : current.public_registration_role;
220
+ await this.validateRegistrationRole(normalizedRegistrationRole);
221
+ if (normalizedRegistrationEnabled && !normalizedRegistrationRole) {
222
+ throw invalid('A non-administrator role must be selected before public registration can be enabled');
223
+ }
224
+ }
225
+
160
226
  const assignments = [];
161
227
  const params = [];
162
228
  if (Object.hasOwn(patch, 'brand_name')) {
@@ -191,13 +257,28 @@ export class StudioSettingsService extends BaseService {
191
257
  assignments.push('default_locale = ?');
192
258
  params.push(normalizeLocale(patch.default_locale));
193
259
  }
260
+ if (Object.hasOwn(patch, 'public_registration_enabled')) {
261
+ assignments.push('public_registration_enabled = ?');
262
+ params.push(normalizedRegistrationEnabled ? 1 : 0);
263
+ }
264
+ if (Object.hasOwn(patch, 'public_registration_role')) {
265
+ assignments.push('public_registration_role = ?');
266
+ params.push(normalizedRegistrationRole);
267
+ }
268
+ if (Object.hasOwn(patch, 'public_registration_require_email_verification')) {
269
+ assignments.push('public_registration_require_email_verification = ?');
270
+ params.push(normalizeBoolean(
271
+ patch.public_registration_require_email_verification,
272
+ 'Public registration email verification requirement',
273
+ ) ? 1 : 0);
274
+ }
194
275
 
195
276
  params.push(1);
196
277
  await this.database.query(
197
278
  `UPDATE yuncms_studio_settings SET ${assignments.join(', ')} WHERE id = ?`,
198
279
  params,
199
280
  );
200
- return this.readPublic();
281
+ return this.readOne();
201
282
  }
202
283
  }
203
284
 
@@ -209,4 +290,7 @@ export const STUDIO_SETTING_DEFAULTS = Object.freeze({
209
290
  accent_color: '#2563eb',
210
291
  theme: 'system',
211
292
  default_locale: 'en',
293
+ public_registration_enabled: false,
294
+ public_registration_role: null,
295
+ public_registration_require_email_verification: false,
212
296
  });
@@ -7,6 +7,7 @@ import { resolveSystemResourceAccess } from './system-resource-access.js';
7
7
 
8
8
  const USER_STATUSES = new Set(['active', 'suspended', 'disabled']);
9
9
  const USER_UPDATE_KEYS = new Set(['email', 'role', 'status']);
10
+ const PUBLIC_REGISTRATION_KEYS = new Set(['email', 'password']);
10
11
 
11
12
  function normalizeEmail(email) {
12
13
  if (typeof email !== 'string') {
@@ -29,6 +30,12 @@ function forbidden(message) {
29
30
  return error;
30
31
  }
31
32
 
33
+ function invalid(message) {
34
+ const error = new Error(message);
35
+ error.code = 'INVALID_PAYLOAD';
36
+ return error;
37
+ }
38
+
32
39
  function assertStatus(status) {
33
40
  if (!USER_STATUSES.has(status)) {
34
41
  const error = new Error(`Unsupported user status: ${status}`);
@@ -154,6 +161,63 @@ export class UsersService extends BaseService {
154
161
  return user;
155
162
  }
156
163
 
164
+ async registerPublic(input = {}) {
165
+ if (this.accountability.public !== true) {
166
+ throw forbidden('Public registration is available only to unauthenticated requests');
167
+ }
168
+
169
+ const [settingsRows] = await this.database.query(
170
+ `SELECT public_registration_enabled, public_registration_role,
171
+ public_registration_require_email_verification
172
+ FROM yuncms_studio_settings
173
+ WHERE id = 1
174
+ LIMIT 1`,
175
+ );
176
+ const settings = settingsRows[0];
177
+ if (!settings) {
178
+ const error = new Error('Studio settings are missing; run YunCMS bootstrap');
179
+ error.code = 'DATABASE_MIGRATION_REQUIRED';
180
+ throw error;
181
+ }
182
+ if (!settings.public_registration_enabled) {
183
+ throw forbidden('Public registration is disabled');
184
+ }
185
+ if (!settings.public_registration_role) {
186
+ throw forbidden('Public registration has no assigned user role');
187
+ }
188
+
189
+ const [roleRows] = await this.database.query(
190
+ 'SELECT id, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
191
+ [settings.public_registration_role],
192
+ );
193
+ const role = roleRows[0];
194
+ if (!role || role.admin || role.public) {
195
+ throw forbidden('Public registration role is invalid');
196
+ }
197
+
198
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
199
+ throw invalid('Public registration payload must be an object');
200
+ }
201
+ const keys = Object.keys(input);
202
+ if (keys.some((key) => !PUBLIC_REGISTRATION_KEYS.has(key))) {
203
+ throw invalid('Public registration accepts email and password only');
204
+ }
205
+
206
+ const email = normalizeEmail(input.email);
207
+ const passwordHash = await hashPassword(input.password);
208
+ const id = randomUUID();
209
+ const verifiedAt = settings.public_registration_require_email_verification ? null : new Date();
210
+ await this.database.query(
211
+ `INSERT INTO yuncms_users (id, email, password_hash, role, status, email_verified_at)
212
+ VALUES (?, ?, ?, ?, 'active', ?)`,
213
+ [id, email, passwordHash, role.id, verifiedAt],
214
+ );
215
+
216
+ const user = await this.#readOneUnsafe(id);
217
+ await this.action('users.create', { key: id, item: user, publicRegistration: true });
218
+ return user;
219
+ }
220
+
157
221
  async updateOne(id, patch = {}) {
158
222
  await resolveSystemResourceAccess(this, 'update', 'yuncms_users');
159
223
  await assertTargetManageable(this.database, id, this.accountability);