@yunsoft/yuncms-core 0.1.0 → 0.1.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/README.md +6 -0
- package/package.json +1 -1
- package/src/auth/users-repository.js +2 -2
- package/src/bootstrap.js +14 -0
- package/src/config.js +4 -1
- package/src/field-types.js +49 -1
- package/src/index.js +24 -3
- package/src/migrations/0005-default-public-role.js +16 -0
- package/src/migrations/0006-studio-settings.js +20 -0
- package/src/migrations/0007-system-permission-resources.js +46 -0
- package/src/o2o-relation.js +231 -0
- package/src/schema-metadata-repository.js +17 -0
- package/src/services/auth-service.js +4 -1
- package/src/services/collections-service.js +56 -8
- package/src/services/core-services.js +2 -0
- package/src/services/fields-service.js +63 -27
- package/src/services/files-service.js +24 -23
- package/src/services/items-service.js +34 -12
- package/src/services/permissions-service.js +48 -13
- package/src/services/roles-service.js +4 -3
- package/src/services/sessions-service.js +3 -3
- package/src/services/studio-settings-service.js +140 -0
- package/src/services/system-resource-access.js +23 -0
- package/src/services/users-service.js +71 -28
- package/src/setup.js +50 -2
- package/src/system-fields.js +146 -0
- package/src/system-permissions.js +54 -0
|
@@ -3,6 +3,11 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { assertPermissionValidationRule } from '../permission-validation.js';
|
|
4
4
|
import { compileFilter } from '../query.js';
|
|
5
5
|
import { SchemaCache } from '../schema.js';
|
|
6
|
+
import {
|
|
7
|
+
assertActionOnlyPermissionPayload,
|
|
8
|
+
assertSystemResourceAction,
|
|
9
|
+
isPermissionManagedSystemResource,
|
|
10
|
+
} from '../system-permissions.js';
|
|
6
11
|
import { BaseService } from './base-service.js';
|
|
7
12
|
|
|
8
13
|
const ACTIONS = new Set(['create', 'read', 'update', 'delete']);
|
|
@@ -81,6 +86,17 @@ export class PermissionsService extends BaseService {
|
|
|
81
86
|
this.schemaCache = options.schemaCache ?? defaultSchemaCache;
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
async #collectionSchema(collection) {
|
|
90
|
+
const snapshot = this.schema ?? await this.schemaCache.get(this.database);
|
|
91
|
+
const collectionSchema = snapshot.collections?.[collection];
|
|
92
|
+
if (!collectionSchema) {
|
|
93
|
+
const error = new Error(`Unknown permission collection: ${collection}`);
|
|
94
|
+
error.code = 'COLLECTION_NOT_FOUND';
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
return collectionSchema;
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
async resolve(action, collection) {
|
|
85
101
|
assertAction(action);
|
|
86
102
|
const key = cacheKey(this.accountability, action, collection);
|
|
@@ -102,6 +118,19 @@ export class PermissionsService extends BaseService {
|
|
|
102
118
|
throw forbidden(`No role is available for ${action} access to ${collection}`);
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
const collectionSchema = await this.#collectionSchema(collection);
|
|
122
|
+
if (collectionSchema.system) {
|
|
123
|
+
if (!isPermissionManagedSystemResource(collectionSchema)) {
|
|
124
|
+
throw forbidden(`System resource is not delegatable: ${collection}`);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
assertSystemResourceAction(collectionSchema, action);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error.code === 'SYSTEM_PERMISSION_ACTION_PROTECTED') throw forbidden(error.message);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
105
134
|
const [rows] = await this.database.query(
|
|
106
135
|
`SELECT id, role, collection, action, fields, filter, validation
|
|
107
136
|
FROM yuncms_permissions
|
|
@@ -154,17 +183,6 @@ export class PermissionsService extends BaseService {
|
|
|
154
183
|
return normalizePermissionRow(rows[0]);
|
|
155
184
|
}
|
|
156
185
|
|
|
157
|
-
async #collectionSchema(collection) {
|
|
158
|
-
const snapshot = this.schema ?? await this.schemaCache.get(this.database);
|
|
159
|
-
const collectionSchema = snapshot.collections?.[collection];
|
|
160
|
-
if (!collectionSchema) {
|
|
161
|
-
const error = new Error(`Unknown permission collection: ${collection}`);
|
|
162
|
-
error.code = 'COLLECTION_NOT_FOUND';
|
|
163
|
-
throw error;
|
|
164
|
-
}
|
|
165
|
-
return collectionSchema;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
186
|
async createOne(input = {}) {
|
|
169
187
|
assertPermissionManager(this.accountability);
|
|
170
188
|
const action = assertAction(input.action);
|
|
@@ -180,6 +198,16 @@ export class PermissionsService extends BaseService {
|
|
|
180
198
|
}
|
|
181
199
|
|
|
182
200
|
const collectionSchema = await this.#collectionSchema(input.collection);
|
|
201
|
+
if (collectionSchema.system) {
|
|
202
|
+
if (!isPermissionManagedSystemResource(collectionSchema)) {
|
|
203
|
+
const error = new Error(`System resource permissions are protected: ${input.collection}`);
|
|
204
|
+
error.code = 'SYSTEM_PERMISSION_RESOURCE_PROTECTED';
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
assertSystemResourceAction(collectionSchema, action);
|
|
208
|
+
assertActionOnlyPermissionPayload(collectionSchema, input);
|
|
209
|
+
}
|
|
210
|
+
|
|
183
211
|
const fields = normalizePermissionFields(input.fields ?? null, collectionSchema);
|
|
184
212
|
const filter = input.filter ?? null;
|
|
185
213
|
const validation = input.validation ?? null;
|
|
@@ -187,14 +215,20 @@ export class PermissionsService extends BaseService {
|
|
|
187
215
|
assertPermissionValidationRule(validation, collectionSchema);
|
|
188
216
|
|
|
189
217
|
const [roleRows] = await this.database.query(
|
|
190
|
-
'SELECT id FROM yuncms_roles WHERE id = ? LIMIT 1',
|
|
218
|
+
'SELECT id, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
|
|
191
219
|
[input.role],
|
|
192
220
|
);
|
|
193
|
-
|
|
221
|
+
const role = roleRows[0];
|
|
222
|
+
if (!role) {
|
|
194
223
|
const error = new Error(`Unknown permission role: ${input.role}`);
|
|
195
224
|
error.code = 'ROLE_NOT_FOUND';
|
|
196
225
|
throw error;
|
|
197
226
|
}
|
|
227
|
+
if (collectionSchema.system && role.public) {
|
|
228
|
+
const error = new Error('Public role cannot be granted access to protected system resources');
|
|
229
|
+
error.code = 'PUBLIC_SYSTEM_ACCESS_FORBIDDEN';
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
198
232
|
|
|
199
233
|
const id = randomUUID();
|
|
200
234
|
await this.database.query(
|
|
@@ -235,6 +269,7 @@ export class PermissionsService extends BaseService {
|
|
|
235
269
|
throw error;
|
|
236
270
|
}
|
|
237
271
|
const collectionSchema = await this.#collectionSchema(existing.collection);
|
|
272
|
+
if (collectionSchema.system) assertActionOnlyPermissionPayload(collectionSchema, patch);
|
|
238
273
|
|
|
239
274
|
const assignments = [];
|
|
240
275
|
const params = [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { BaseService } from './base-service.js';
|
|
4
|
+
import { resolveSystemResourceAccess } from './system-resource-access.js';
|
|
4
5
|
|
|
5
6
|
function assertRoleManager(accountability) {
|
|
6
7
|
if (accountability.admin === true || accountability.system === true) return;
|
|
@@ -26,7 +27,7 @@ function normalizeRoleName(name) {
|
|
|
26
27
|
|
|
27
28
|
export class RolesService extends BaseService {
|
|
28
29
|
async readMany() {
|
|
29
|
-
|
|
30
|
+
await resolveSystemResourceAccess(this, 'read', 'yuncms_roles');
|
|
30
31
|
const [rows] = await this.database.query(
|
|
31
32
|
`SELECT id, name, description, admin, public, created_at, updated_at
|
|
32
33
|
FROM yuncms_roles
|
|
@@ -36,7 +37,7 @@ export class RolesService extends BaseService {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async readOne(id) {
|
|
39
|
-
|
|
40
|
+
await resolveSystemResourceAccess(this, 'read', 'yuncms_roles');
|
|
40
41
|
const [rows] = await this.database.query(
|
|
41
42
|
`SELECT id, name, description, admin, public, created_at, updated_at
|
|
42
43
|
FROM yuncms_roles
|
|
@@ -157,4 +158,4 @@ export class RolesService extends BaseService {
|
|
|
157
158
|
}
|
|
158
159
|
return true;
|
|
159
160
|
}
|
|
160
|
-
}
|
|
161
|
+
}
|
|
@@ -20,6 +20,7 @@ function identityFromRow(row) {
|
|
|
20
20
|
return {
|
|
21
21
|
user: row.user,
|
|
22
22
|
role: row.role ?? null,
|
|
23
|
+
role_name: row.role_name ?? null,
|
|
23
24
|
admin: Boolean(row.role_admin),
|
|
24
25
|
email: row.email,
|
|
25
26
|
session: row.session_id,
|
|
@@ -71,7 +72,7 @@ export class SessionsService extends BaseService {
|
|
|
71
72
|
const hash = hashToken(token);
|
|
72
73
|
const [rows] = await this.database.query(
|
|
73
74
|
`SELECT s.id AS session_id, s.user, u.email, u.role, u.status,
|
|
74
|
-
r.admin AS role_admin
|
|
75
|
+
r.name AS role_name, r.admin AS role_admin
|
|
75
76
|
FROM yuncms_sessions s
|
|
76
77
|
INNER JOIN yuncms_users u ON u.id = s.user
|
|
77
78
|
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
@@ -105,7 +106,7 @@ export class SessionsService extends BaseService {
|
|
|
105
106
|
const oldHash = hashToken(token);
|
|
106
107
|
const [rows] = await this.database.query(
|
|
107
108
|
`SELECT s.id AS session_id, s.user, u.email, u.role, u.status,
|
|
108
|
-
r.admin AS role_admin
|
|
109
|
+
r.name AS role_name, r.admin AS role_admin
|
|
109
110
|
FROM yuncms_sessions s
|
|
110
111
|
INNER JOIN yuncms_users u ON u.id = s.user
|
|
111
112
|
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
@@ -164,7 +165,6 @@ export class SessionsService extends BaseService {
|
|
|
164
165
|
error.code = 'FORBIDDEN';
|
|
165
166
|
throw error;
|
|
166
167
|
}
|
|
167
|
-
|
|
168
168
|
const [result] = await this.database.query(
|
|
169
169
|
'DELETE FROM yuncms_sessions WHERE user = ?',
|
|
170
170
|
[userId],
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { BaseService } from './base-service.js';
|
|
2
|
+
|
|
3
|
+
const THEMES = new Set(['system', 'light', 'dark']);
|
|
4
|
+
const LOCALES = new Set(['en', 'tr']);
|
|
5
|
+
const ACCENT_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
6
|
+
|
|
7
|
+
function invalid(message) {
|
|
8
|
+
const error = new Error(message);
|
|
9
|
+
error.code = 'INVALID_PAYLOAD';
|
|
10
|
+
return error;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertManager(accountability) {
|
|
14
|
+
if (accountability.admin === true || accountability.system === true) return;
|
|
15
|
+
const error = new Error('Studio settings require administrator accountability');
|
|
16
|
+
error.code = 'FORBIDDEN';
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeBrandName(value) {
|
|
21
|
+
if (typeof value !== 'string' || !value.trim()) throw invalid('Brand name is required');
|
|
22
|
+
const normalized = value.trim();
|
|
23
|
+
if (normalized.length > 100) throw invalid('Brand name cannot exceed 100 characters');
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeLogoUrl(value) {
|
|
28
|
+
if (typeof value !== 'string' || !value.trim()) throw invalid('Logo URL is required');
|
|
29
|
+
const normalized = value.trim();
|
|
30
|
+
if (normalized.length > 512) throw invalid('Logo URL cannot exceed 512 characters');
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(normalized);
|
|
34
|
+
} catch {
|
|
35
|
+
throw invalid('Logo URL must be a valid URL');
|
|
36
|
+
}
|
|
37
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw invalid('Logo URL must use HTTP or HTTPS');
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeAccent(value) {
|
|
42
|
+
const normalized = String(value ?? '').trim();
|
|
43
|
+
if (!ACCENT_PATTERN.test(normalized)) throw invalid('Accent color must be a six-digit hex color');
|
|
44
|
+
return normalized.toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeTheme(value) {
|
|
48
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
49
|
+
if (!THEMES.has(normalized)) throw invalid('Theme must be system, light or dark');
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeLocale(value) {
|
|
54
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
55
|
+
if (!LOCALES.has(normalized)) throw invalid('Default locale must be en or tr');
|
|
56
|
+
return normalized;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function publicSettings(row) {
|
|
60
|
+
return {
|
|
61
|
+
brand_name: row.brand_name,
|
|
62
|
+
logo_url: row.logo_url,
|
|
63
|
+
accent_color: row.accent_color,
|
|
64
|
+
theme: row.theme,
|
|
65
|
+
default_locale: row.default_locale,
|
|
66
|
+
updated_at: row.updated_at ?? null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class StudioSettingsService extends BaseService {
|
|
71
|
+
async readPublic() {
|
|
72
|
+
const [rows] = await this.database.query(
|
|
73
|
+
`SELECT brand_name, logo_url, accent_color, theme, default_locale, updated_at
|
|
74
|
+
FROM yuncms_studio_settings
|
|
75
|
+
WHERE id = 1
|
|
76
|
+
LIMIT 1`,
|
|
77
|
+
);
|
|
78
|
+
const row = rows[0];
|
|
79
|
+
if (!row) {
|
|
80
|
+
const error = new Error('Studio settings are missing; run YunCMS bootstrap');
|
|
81
|
+
error.code = 'DATABASE_MIGRATION_REQUIRED';
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
return publicSettings(row);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async readOne() {
|
|
88
|
+
assertManager(this.accountability);
|
|
89
|
+
return this.readPublic();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async updateOne(patch = {}) {
|
|
93
|
+
assertManager(this.accountability);
|
|
94
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid('Studio settings patch must be an object');
|
|
95
|
+
|
|
96
|
+
const keys = Object.keys(patch);
|
|
97
|
+
const allowed = new Set(['brand_name', 'logo_url', 'accent_color', 'theme', 'default_locale']);
|
|
98
|
+
if (keys.length === 0 || keys.some((key) => !allowed.has(key))) {
|
|
99
|
+
throw invalid('Studio settings patch contains unsupported properties');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const assignments = [];
|
|
103
|
+
const params = [];
|
|
104
|
+
if (Object.hasOwn(patch, 'brand_name')) {
|
|
105
|
+
assignments.push('brand_name = ?');
|
|
106
|
+
params.push(normalizeBrandName(patch.brand_name));
|
|
107
|
+
}
|
|
108
|
+
if (Object.hasOwn(patch, 'logo_url')) {
|
|
109
|
+
assignments.push('logo_url = ?');
|
|
110
|
+
params.push(normalizeLogoUrl(patch.logo_url));
|
|
111
|
+
}
|
|
112
|
+
if (Object.hasOwn(patch, 'accent_color')) {
|
|
113
|
+
assignments.push('accent_color = ?');
|
|
114
|
+
params.push(normalizeAccent(patch.accent_color));
|
|
115
|
+
}
|
|
116
|
+
if (Object.hasOwn(patch, 'theme')) {
|
|
117
|
+
assignments.push('theme = ?');
|
|
118
|
+
params.push(normalizeTheme(patch.theme));
|
|
119
|
+
}
|
|
120
|
+
if (Object.hasOwn(patch, 'default_locale')) {
|
|
121
|
+
assignments.push('default_locale = ?');
|
|
122
|
+
params.push(normalizeLocale(patch.default_locale));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
params.push(1);
|
|
126
|
+
await this.database.query(
|
|
127
|
+
`UPDATE yuncms_studio_settings SET ${assignments.join(', ')} WHERE id = ?`,
|
|
128
|
+
params,
|
|
129
|
+
);
|
|
130
|
+
return this.readPublic();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const STUDIO_SETTING_DEFAULTS = Object.freeze({
|
|
135
|
+
brand_name: 'YunCMS',
|
|
136
|
+
logo_url: 'https://yunsoft.com/light-logo.png',
|
|
137
|
+
accent_color: '#2563eb',
|
|
138
|
+
theme: 'system',
|
|
139
|
+
default_locale: 'en',
|
|
140
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PermissionsService } from './permissions-service.js';
|
|
2
|
+
|
|
3
|
+
export async function resolveSystemResourceAccess(service, action, collection) {
|
|
4
|
+
if (service.accountability.admin === true || service.accountability.system === true) {
|
|
5
|
+
return {
|
|
6
|
+
fullAccess: true,
|
|
7
|
+
action,
|
|
8
|
+
collection,
|
|
9
|
+
role: service.accountability.role ?? null,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const permissions = new PermissionsService({
|
|
14
|
+
accountability: service.accountability,
|
|
15
|
+
database: service.database,
|
|
16
|
+
schema: service.schema,
|
|
17
|
+
emitter: service.emitter,
|
|
18
|
+
logger: service.logger,
|
|
19
|
+
permissionCache: service.permissionCache,
|
|
20
|
+
requestId: service.requestId,
|
|
21
|
+
});
|
|
22
|
+
return permissions.resolve(action, collection);
|
|
23
|
+
}
|
|
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { hashPassword } from '../auth/password.js';
|
|
4
4
|
import { withTransaction } from '../transaction.js';
|
|
5
5
|
import { BaseService } from './base-service.js';
|
|
6
|
+
import { resolveSystemResourceAccess } from './system-resource-access.js';
|
|
6
7
|
|
|
7
8
|
const USER_STATUSES = new Set(['active', 'suspended', 'disabled']);
|
|
8
9
|
const USER_UPDATE_KEYS = new Set(['email', 'role', 'status']);
|
|
@@ -22,11 +23,10 @@ function normalizeEmail(email) {
|
|
|
22
23
|
return normalized;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
const error = new Error('User management requires administrator accountability');
|
|
26
|
+
function forbidden(message) {
|
|
27
|
+
const error = new Error(message);
|
|
28
28
|
error.code = 'FORBIDDEN';
|
|
29
|
-
|
|
29
|
+
return error;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function assertStatus(status) {
|
|
@@ -38,19 +38,65 @@ function assertStatus(status) {
|
|
|
38
38
|
return status;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
function assertCredentialManager(accountability) {
|
|
42
|
+
if (accountability.admin === true || accountability.system === true) return;
|
|
43
|
+
throw forbidden('Changing another user password requires administrator accountability');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function assertRoleAssignable(database, role, accountability) {
|
|
42
47
|
if (role == null) return;
|
|
43
|
-
const [roleRows] = await database.query(
|
|
44
|
-
|
|
48
|
+
const [roleRows] = await database.query(
|
|
49
|
+
'SELECT id, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
|
|
50
|
+
[role],
|
|
51
|
+
);
|
|
52
|
+
const targetRole = roleRows[0];
|
|
53
|
+
if (!targetRole) {
|
|
45
54
|
const error = new Error(`Unknown role: ${role}`);
|
|
46
55
|
error.code = 'ROLE_NOT_FOUND';
|
|
47
56
|
throw error;
|
|
48
57
|
}
|
|
58
|
+
if (targetRole.public) {
|
|
59
|
+
const error = new Error('The public role cannot be assigned to an authenticated user');
|
|
60
|
+
error.code = 'INVALID_ROLE';
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
if (targetRole.admin && accountability.admin !== true && accountability.system !== true) {
|
|
64
|
+
throw forbidden('Only an administrator can assign the administrator role');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function assertTargetManageable(database, id, accountability) {
|
|
69
|
+
if (accountability.admin === true || accountability.system === true) return;
|
|
70
|
+
const [rows] = await database.query(
|
|
71
|
+
`SELECT u.id, r.admin AS role_admin
|
|
72
|
+
FROM yuncms_users u
|
|
73
|
+
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
74
|
+
WHERE u.id = ?
|
|
75
|
+
LIMIT 1`,
|
|
76
|
+
[id],
|
|
77
|
+
);
|
|
78
|
+
if (!rows[0]) {
|
|
79
|
+
const error = new Error(`Unknown user: ${id}`);
|
|
80
|
+
error.code = 'USER_NOT_FOUND';
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
if (rows[0].role_admin) throw forbidden('Delegated user managers cannot modify administrator accounts');
|
|
49
84
|
}
|
|
50
85
|
|
|
51
86
|
export class UsersService extends BaseService {
|
|
87
|
+
async #readOneUnsafe(id) {
|
|
88
|
+
const [rows] = await this.database.query(
|
|
89
|
+
`SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
|
|
90
|
+
FROM yuncms_users
|
|
91
|
+
WHERE id = ?
|
|
92
|
+
LIMIT 1`,
|
|
93
|
+
[id],
|
|
94
|
+
);
|
|
95
|
+
return rows[0] ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
52
98
|
async readMany() {
|
|
53
|
-
|
|
99
|
+
await resolveSystemResourceAccess(this, 'read', 'yuncms_users');
|
|
54
100
|
const [rows] = await this.database.query(
|
|
55
101
|
`SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
|
|
56
102
|
FROM yuncms_users
|
|
@@ -61,26 +107,19 @@ export class UsersService extends BaseService {
|
|
|
61
107
|
|
|
62
108
|
async readOne(id) {
|
|
63
109
|
const self = this.accountability.user === id;
|
|
64
|
-
if (!self)
|
|
65
|
-
|
|
66
|
-
const [rows] = await this.database.query(
|
|
67
|
-
`SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
|
|
68
|
-
FROM yuncms_users
|
|
69
|
-
WHERE id = ?
|
|
70
|
-
LIMIT 1`,
|
|
71
|
-
[id],
|
|
72
|
-
);
|
|
73
|
-
return rows[0] ?? null;
|
|
110
|
+
if (!self) await resolveSystemResourceAccess(this, 'read', 'yuncms_users');
|
|
111
|
+
return this.#readOneUnsafe(id);
|
|
74
112
|
}
|
|
75
113
|
|
|
76
114
|
async createOne(input = {}) {
|
|
77
|
-
|
|
115
|
+
await resolveSystemResourceAccess(this, 'create', 'yuncms_users');
|
|
78
116
|
const email = normalizeEmail(input.email);
|
|
79
117
|
const status = assertStatus(input.status ?? 'active');
|
|
80
|
-
await
|
|
118
|
+
await assertRoleAssignable(this.database, input.role ?? null, this.accountability);
|
|
81
119
|
|
|
82
120
|
const passwordHash = await hashPassword(input.password);
|
|
83
121
|
const id = randomUUID();
|
|
122
|
+
const verifiedAt = new Date();
|
|
84
123
|
await this.database.query(
|
|
85
124
|
`INSERT INTO yuncms_users (id, email, password_hash, role, status, email_verified_at)
|
|
86
125
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
@@ -90,15 +129,16 @@ export class UsersService extends BaseService {
|
|
|
90
129
|
passwordHash,
|
|
91
130
|
input.role ?? null,
|
|
92
131
|
status,
|
|
93
|
-
|
|
132
|
+
verifiedAt,
|
|
94
133
|
],
|
|
95
134
|
);
|
|
96
135
|
|
|
97
|
-
return this
|
|
136
|
+
return this.#readOneUnsafe(id);
|
|
98
137
|
}
|
|
99
138
|
|
|
100
139
|
async updateOne(id, patch = {}) {
|
|
101
|
-
|
|
140
|
+
await resolveSystemResourceAccess(this, 'update', 'yuncms_users');
|
|
141
|
+
await assertTargetManageable(this.database, id, this.accountability);
|
|
102
142
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
103
143
|
const error = new Error('User patch must be an object');
|
|
104
144
|
error.code = 'INVALID_PAYLOAD';
|
|
@@ -114,12 +154,14 @@ export class UsersService extends BaseService {
|
|
|
114
154
|
if (Object.hasOwn(patch, 'status')) {
|
|
115
155
|
assertStatus(patch.status);
|
|
116
156
|
if (this.accountability.user === id && patch.status !== 'active') {
|
|
117
|
-
const error = new Error('
|
|
157
|
+
const error = new Error('A user cannot suspend or disable their own active session');
|
|
118
158
|
error.code = 'SELF_ADMIN_MUTATION_FORBIDDEN';
|
|
119
159
|
throw error;
|
|
120
160
|
}
|
|
121
161
|
}
|
|
122
|
-
if (Object.hasOwn(patch, 'role'))
|
|
162
|
+
if (Object.hasOwn(patch, 'role')) {
|
|
163
|
+
await assertRoleAssignable(this.database, patch.role, this.accountability);
|
|
164
|
+
}
|
|
123
165
|
|
|
124
166
|
return withTransaction(this.database, async (connection) => {
|
|
125
167
|
const assignments = [];
|
|
@@ -163,9 +205,10 @@ export class UsersService extends BaseService {
|
|
|
163
205
|
}
|
|
164
206
|
|
|
165
207
|
async deleteOne(id) {
|
|
166
|
-
|
|
208
|
+
await resolveSystemResourceAccess(this, 'delete', 'yuncms_users');
|
|
209
|
+
await assertTargetManageable(this.database, id, this.accountability);
|
|
167
210
|
if (this.accountability.user === id) {
|
|
168
|
-
const error = new Error('
|
|
211
|
+
const error = new Error('A user cannot delete their own account from an active session');
|
|
169
212
|
error.code = 'SELF_ADMIN_MUTATION_FORBIDDEN';
|
|
170
213
|
throw error;
|
|
171
214
|
}
|
|
@@ -180,7 +223,7 @@ export class UsersService extends BaseService {
|
|
|
180
223
|
|
|
181
224
|
async updatePassword(id, password) {
|
|
182
225
|
const self = this.accountability.user === id;
|
|
183
|
-
if (!self)
|
|
226
|
+
if (!self) assertCredentialManager(this.accountability);
|
|
184
227
|
|
|
185
228
|
const passwordHash = await hashPassword(password);
|
|
186
229
|
const connection = await this.database.getConnection();
|
package/src/setup.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
import { createSystemAccountability } from './accountability.js';
|
|
2
4
|
import { withTransaction } from './transaction.js';
|
|
3
5
|
import { RolesService } from './services/roles-service.js';
|
|
4
6
|
import { UsersService } from './services/users-service.js';
|
|
5
7
|
|
|
8
|
+
const PUBLIC_ROLE_NAMES = Object.freeze(['Public', 'Public API', 'Anonymous']);
|
|
9
|
+
|
|
6
10
|
export async function findExistingAdmin(database) {
|
|
7
11
|
if (!database) throw new Error('Database handle is required');
|
|
8
12
|
const [rows] = await database.query(
|
|
@@ -16,6 +20,51 @@ export async function findExistingAdmin(database) {
|
|
|
16
20
|
return rows[0] ?? null;
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
export async function findPublicRole(database) {
|
|
24
|
+
if (!database) throw new Error('Database handle is required');
|
|
25
|
+
const [rows] = await database.query(
|
|
26
|
+
`SELECT id, name, description, admin, public
|
|
27
|
+
FROM yuncms_roles
|
|
28
|
+
WHERE public = 1
|
|
29
|
+
ORDER BY created_at ASC
|
|
30
|
+
LIMIT 2`,
|
|
31
|
+
);
|
|
32
|
+
if (rows.length > 1) {
|
|
33
|
+
const error = new Error('Multiple public roles are configured');
|
|
34
|
+
error.code = 'PUBLIC_ROLE_AMBIGUOUS';
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return rows[0] ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function availablePublicRoleName(database) {
|
|
41
|
+
for (const candidate of PUBLIC_ROLE_NAMES) {
|
|
42
|
+
const [rows] = await database.query(
|
|
43
|
+
'SELECT id FROM yuncms_roles WHERE name = ? LIMIT 1',
|
|
44
|
+
[candidate],
|
|
45
|
+
);
|
|
46
|
+
if (!rows[0]) return candidate;
|
|
47
|
+
}
|
|
48
|
+
return `Public ${randomUUID().slice(0, 8)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function ensurePublicRole(database) {
|
|
52
|
+
if (!database) throw new Error('Database handle is required');
|
|
53
|
+
const existing = await findPublicRole(database);
|
|
54
|
+
if (existing) return { ...existing, created: false };
|
|
55
|
+
|
|
56
|
+
const roles = new RolesService({
|
|
57
|
+
accountability: createSystemAccountability(),
|
|
58
|
+
database,
|
|
59
|
+
});
|
|
60
|
+
const role = await roles.createOne({
|
|
61
|
+
name: await availablePublicRoleName(database),
|
|
62
|
+
description: 'Unauthenticated public API access. No collection access is granted by default.',
|
|
63
|
+
public: true,
|
|
64
|
+
});
|
|
65
|
+
return { ...role, created: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
19
68
|
export async function createInitialAdmin(pool, { email, password } = {}) {
|
|
20
69
|
if (!pool) throw new Error('Database pool is required');
|
|
21
70
|
const accountability = createSystemAccountability();
|
|
@@ -54,7 +103,6 @@ export async function createInitialAdmin(pool, { email, password } = {}) {
|
|
|
54
103
|
password,
|
|
55
104
|
role: roleId,
|
|
56
105
|
status: 'active',
|
|
57
|
-
emailVerified: true,
|
|
58
106
|
});
|
|
59
107
|
|
|
60
108
|
return {
|
|
@@ -63,4 +111,4 @@ export async function createInitialAdmin(pool, { email, password } = {}) {
|
|
|
63
111
|
role: roleId,
|
|
64
112
|
};
|
|
65
113
|
});
|
|
66
|
-
}
|
|
114
|
+
}
|