@yunsoft/yuncms-core 0.1.3 → 0.1.6

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.
@@ -3,10 +3,14 @@ import { randomUUID } from 'node:crypto';
3
3
  import { assertIdentifier, quoteIdentifier } from '../identifier.js';
4
4
  import { enforcePermissionValidation } from '../permission-validation.js';
5
5
  import {
6
+ assertQueryCost,
7
+ compileAggregate,
6
8
  compileFilter,
9
+ compileSearch,
7
10
  compileSelectFields,
8
11
  compileSort,
9
12
  parseItemsQuery,
13
+ QUERY_LIMITS,
10
14
  } from '../query.js';
11
15
  import { SchemaCache } from '../schema.js';
12
16
  import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
@@ -175,10 +179,37 @@ export class ItemsService extends BaseService {
175
179
  return entries;
176
180
  }
177
181
 
178
- compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema) {
182
+ compileActionFilters(userFilter, permissionFilter, userSchema, fullSchema, search = null) {
179
183
  const permissionSql = compileFilter(permissionFilter, fullSchema);
180
184
  const userSql = compileFilter(userFilter, userSchema);
181
- return combineCompiledFilters(permissionSql, userSql);
185
+ const searchSql = compileSearch(search, userSchema);
186
+ return combineCompiledFilters(permissionSql, userSql, searchSql);
187
+ }
188
+
189
+ async normalizeReadQuery(rawQuery) {
190
+ const parsed = parseItemsQuery(rawQuery);
191
+ const filtered = this.emitter
192
+ ? await this.emitter.filter('items.query', parsed, this.hookContext({ operation: 'read' }))
193
+ : parsed;
194
+ const query = parseItemsQuery(filtered);
195
+ assertQueryCost(query);
196
+ return query;
197
+ }
198
+
199
+ async emitReadAction(query, rows, schema, { single = false } = {}) {
200
+ if (!this.emitter) return;
201
+ const primaryKey = schema.primary_key;
202
+ const keys = rows
203
+ .filter((row) => row && Object.hasOwn(row, primaryKey))
204
+ .map((row) => row[primaryKey])
205
+ .slice(0, QUERY_LIMITS.maxLimit);
206
+ await this.emitter.action('items.read', {
207
+ collection: this.collection,
208
+ query,
209
+ keys,
210
+ count: rows.length,
211
+ single,
212
+ }, this.hookContext({ operation: 'read' }));
182
213
  }
183
214
 
184
215
  async readMany(rawQuery = {}) {
@@ -187,15 +218,34 @@ export class ItemsService extends BaseService {
187
218
 
188
219
  async readManyWithMeta(rawQuery = {}) {
189
220
  const schema = await this.getCollectionSchema();
221
+ const query = await this.normalizeReadQuery(rawQuery);
190
222
  const permission = await this.resolvePermission('read');
191
223
  const accessSchema = schemaForFields(schema, permission.fields);
192
- const query = parseItemsQuery(rawQuery);
224
+ const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema, query.search);
225
+ const table = quoteIdentifier(this.collection, 'collection name');
226
+ const aggregate = compileAggregate(query.aggregate, query.groupBy, accessSchema);
227
+
228
+ if (aggregate) {
229
+ const sortSql = compileSort(query.sort, accessSchema);
230
+ const [rows] = await this.database.query(
231
+ `SELECT ${aggregate.sql} FROM ${table}${filter.sql}${aggregate.groupSql}${sortSql} LIMIT ? OFFSET ?`,
232
+ [...filter.params, query.limit, query.offset],
233
+ );
234
+ await this.emitReadAction(query, rows, schema);
235
+ return {
236
+ data: rows,
237
+ meta: {
238
+ total_count: rows.length,
239
+ limit: query.limit,
240
+ offset: query.offset,
241
+ aggregate: true,
242
+ },
243
+ };
244
+ }
245
+
193
246
  const requestedFields = normalizeFields(query.fields);
194
247
  const selected = compileSelectFields(requestedFields, accessSchema);
195
- const filter = this.compileActionFilters(query.filter, permission.filter, accessSchema, schema);
196
248
  const sortSql = compileSort(query.sort, accessSchema);
197
- const table = quoteIdentifier(this.collection, 'collection name');
198
-
199
249
  const [rows] = await this.database.query(
200
250
  `SELECT ${selected.sql} FROM ${table}${filter.sql}${sortSql} LIMIT ? OFFSET ?`,
201
251
  [...filter.params, query.limit, query.offset],
@@ -204,6 +254,7 @@ export class ItemsService extends BaseService {
204
254
  `SELECT COUNT(*) AS total_count FROM ${table}${filter.sql}`,
205
255
  filter.params,
206
256
  );
257
+ await this.emitReadAction(query, rows, schema);
207
258
 
208
259
  return {
209
260
  data: rows,
@@ -215,6 +266,48 @@ export class ItemsService extends BaseService {
215
266
  };
216
267
  }
217
268
 
269
+ async readManyForRelation({ fields = null, lookupField, values = [] } = {}) {
270
+ const schema = await this.getCollectionSchema();
271
+ const trustedLookupField = assertIdentifier(lookupField, 'relation lookup field');
272
+ if (!schema.fields[trustedLookupField]) {
273
+ throw serviceError('INVALID_QUERY', `Unknown relation lookup field: ${trustedLookupField}`, trustedLookupField);
274
+ }
275
+ if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxLimit) {
276
+ throw serviceError(
277
+ 'INVALID_QUERY',
278
+ `Relation lookup values must contain between 1 and ${QUERY_LIMITS.maxLimit} entries`,
279
+ trustedLookupField,
280
+ );
281
+ }
282
+
283
+ const permission = await this.resolvePermission('read');
284
+ const accessSchema = schemaForFields(schema, permission.fields);
285
+ const visibleSelection = compileSelectFields(normalizeFields(fields), accessSchema);
286
+ const internalSchema = {
287
+ ...accessSchema,
288
+ fields: { ...accessSchema.fields, [trustedLookupField]: schema.fields[trustedLookupField] },
289
+ };
290
+ const internalSelection = compileSelectFields([...visibleSelection.fields, trustedLookupField], internalSchema);
291
+ const permissionFilter = compileFilter(permission.filter, schema);
292
+ const table = quoteIdentifier(this.collection, 'collection name');
293
+ const data = [];
294
+
295
+ for (let offset = 0; offset < values.length; offset += QUERY_LIMITS.maxInValues) {
296
+ const chunk = values.slice(offset, offset + QUERY_LIMITS.maxInValues);
297
+ const filter = combineCompiledFilters(
298
+ permissionFilter,
299
+ compileFilter({ [trustedLookupField]: { _in: chunk } }, schema),
300
+ );
301
+ const [rows] = await this.database.query(
302
+ `SELECT ${internalSelection.sql} FROM ${table}${filter.sql} LIMIT ?`,
303
+ [...filter.params, chunk.length],
304
+ );
305
+ data.push(...rows);
306
+ }
307
+
308
+ return { data, visibleFields: visibleSelection.fields };
309
+ }
310
+
218
311
  async readOne(id, { fields = null } = {}) {
219
312
  const schema = await this.getCollectionSchema();
220
313
  const permission = await this.resolvePermission('read');
@@ -230,7 +323,9 @@ export class ItemsService extends BaseService {
230
323
  `SELECT ${selected.sql} FROM ${table}${filter.sql} LIMIT 1`,
231
324
  filter.params,
232
325
  );
233
- return rows[0] ?? null;
326
+ const record = rows[0] ?? null;
327
+ if (record) await this.emitReadAction({ fields: normalizeFields(fields) }, [record], schema, { single: true });
328
+ return record;
234
329
  }
235
330
 
236
331
  async returnCreatedOrUpdated(id, schema) {
@@ -255,10 +350,8 @@ export class ItemsService extends BaseService {
255
350
  const values = { [schema.primary_key]: id, ...Object.fromEntries(entries) };
256
351
  const fields = Object.keys(values);
257
352
  const table = quoteIdentifier(this.collection, 'collection name');
258
-
259
353
  await this.database.query(
260
- `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
261
- VALUES (${fields.map(() => '?').join(', ')})`,
354
+ `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
262
355
  fields.map((field) => values[field]),
263
356
  );
264
357
 
@@ -277,29 +370,21 @@ export class ItemsService extends BaseService {
277
370
  const staged = [];
278
371
 
279
372
  for (const payload of payloads) {
280
- const filteredPayload = await this.filterMutation('items.create', payload, {
281
- operation: 'create',
282
- bulk: true,
283
- });
373
+ const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create', bulk: true });
284
374
  const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
285
375
  const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
286
376
  const id = randomUUID();
287
377
  const candidate = createCandidateRecord(schema, id, entries);
288
378
  enforcePermissionValidation(candidate, permission.validation, schema);
289
- staged.push({
290
- id,
291
- values: { [schema.primary_key]: id, ...Object.fromEntries(entries) },
292
- });
379
+ staged.push({ id, values: { [schema.primary_key]: id, ...Object.fromEntries(entries) } });
293
380
  }
294
381
 
295
382
  await withTransaction(this.database, async (connection) => {
296
383
  const table = quoteIdentifier(this.collection, 'collection name');
297
-
298
384
  for (const entry of staged) {
299
385
  const fields = Object.keys(entry.values);
300
386
  await connection.query(
301
- `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})
302
- VALUES (${fields.map(() => '?').join(', ')})`,
387
+ `INSERT INTO ${table} (${fields.map((field) => quoteIdentifier(field, 'field name')).join(', ')})\n VALUES (${fields.map(() => '?').join(', ')})`,
303
388
  fields.map((field) => entry.values[field]),
304
389
  );
305
390
  }
@@ -309,10 +394,7 @@ export class ItemsService extends BaseService {
309
394
  for (const entry of staged) {
310
395
  const record = await this.returnCreatedOrUpdated(entry.id, schema);
311
396
  records.push(record);
312
- await this.actionMutation('items.create', { key: entry.id, item: record }, {
313
- operation: 'create',
314
- bulk: true,
315
- });
397
+ await this.actionMutation('items.create', { key: entry.id, item: record }, { operation: 'create', bulk: true });
316
398
  }
317
399
  return records;
318
400
  }
@@ -320,15 +402,11 @@ export class ItemsService extends BaseService {
320
402
  async updateOne(id, payload = {}) {
321
403
  const schema = await this.getCollectionSchema();
322
404
  const permission = await this.resolvePermission('update');
323
- const filteredPayload = await this.filterMutation('items.update', payload, {
324
- operation: 'update',
325
- key: id,
326
- });
405
+ const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', key: id });
327
406
  const callerEntries = this.validatePayload(filteredPayload, schema, permission);
328
407
  if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
329
408
  const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
330
409
  const effectiveChanges = Object.fromEntries(entries);
331
-
332
410
  const table = quoteIdentifier(this.collection, 'collection name');
333
411
  const filter = combineCompiledFilters(
334
412
  compileFilter(permission.filter, schema),
@@ -336,10 +414,7 @@ export class ItemsService extends BaseService {
336
414
  );
337
415
 
338
416
  if (permission.validation) {
339
- const [currentRows] = await this.database.query(
340
- `SELECT * FROM ${table}${filter.sql} LIMIT 1`,
341
- filter.params,
342
- );
417
+ const [currentRows] = await this.database.query(`SELECT * FROM ${table}${filter.sql} LIMIT 1`, filter.params);
343
418
  const current = currentRows[0];
344
419
  if (!current) return null;
345
420
  enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema);
@@ -350,14 +425,9 @@ export class ItemsService extends BaseService {
350
425
  `UPDATE ${table} SET ${setSql}${filter.sql}`,
351
426
  [...entries.map(([, value]) => value), ...filter.params],
352
427
  );
353
-
354
428
  if (result.affectedRows === 0) return null;
355
429
  const record = await this.returnCreatedOrUpdated(id, schema);
356
- await this.actionMutation('items.update', {
357
- key: id,
358
- item: record,
359
- changes: effectiveChanges,
360
- }, { operation: 'update' });
430
+ await this.actionMutation('items.update', { key: id, item: record, changes: effectiveChanges }, { operation: 'update' });
361
431
  return record;
362
432
  }
363
433
 
@@ -369,11 +439,7 @@ export class ItemsService extends BaseService {
369
439
  const schema = await this.getCollectionSchema();
370
440
  const permission = await this.resolvePermission('update');
371
441
  const accessSchema = schemaForFields(schema, permission.fields);
372
- const filteredPayload = await this.filterMutation('items.update', payload, {
373
- operation: 'update',
374
- bulk: true,
375
- filter: filterInput,
376
- });
442
+ const filteredPayload = await this.filterMutation('items.update', payload, { operation: 'update', bulk: true, filter: filterInput });
377
443
  const callerEntries = this.validatePayload(filteredPayload, schema, permission);
378
444
  if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
379
445
  const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
@@ -387,14 +453,9 @@ export class ItemsService extends BaseService {
387
453
  [...filter.params, MAX_BULK_VALIDATION_ROWS + 1],
388
454
  );
389
455
  if (rows.length > MAX_BULK_VALIDATION_ROWS) {
390
- throw serviceError(
391
- 'VALIDATION_BULK_LIMIT',
392
- `Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`,
393
- );
394
- }
395
- for (const row of rows) {
396
- enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
456
+ throw serviceError('VALIDATION_BULK_LIMIT', `Permission validation can inspect at most ${MAX_BULK_VALIDATION_ROWS} rows per bulk update`);
397
457
  }
458
+ for (const row of rows) enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
398
459
  }
399
460
 
400
461
  const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
@@ -402,35 +463,23 @@ export class ItemsService extends BaseService {
402
463
  `UPDATE ${table} SET ${setSql}${filter.sql}`,
403
464
  [...entries.map(([, value]) => value), ...filter.params],
404
465
  );
405
- await this.actionMutation('items.update', {
406
- filter: filterInput,
407
- changes: effectiveChanges,
408
- affected: result.affectedRows,
409
- }, { operation: 'update', bulk: true });
466
+ await this.actionMutation('items.update', { filter: filterInput, changes: effectiveChanges, affected: result.affectedRows }, { operation: 'update', bulk: true });
410
467
  return result.affectedRows;
411
468
  }
412
469
 
413
470
  async deleteOne(id) {
414
471
  const schema = await this.getCollectionSchema();
415
472
  const permission = await this.resolvePermission('delete');
416
- const filtered = await this.filterMutation('items.delete', { key: id }, {
417
- operation: 'delete',
418
- key: id,
419
- });
473
+ const filtered = await this.filterMutation('items.delete', { key: id }, { operation: 'delete', key: id });
420
474
  const key = filtered?.key ?? id;
421
475
  const table = quoteIdentifier(this.collection, 'collection name');
422
476
  const filter = combineCompiledFilters(
423
477
  compileFilter(permission.filter, schema),
424
478
  compileFilter({ [schema.primary_key]: { _eq: key } }, schema),
425
479
  );
426
- const [result] = await this.database.query(
427
- `DELETE FROM ${table}${filter.sql}`,
428
- filter.params,
429
- );
480
+ const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
430
481
  const deleted = result.affectedRows > 0;
431
- if (deleted) {
432
- await this.actionMutation('items.delete', { key }, { operation: 'delete' });
433
- }
482
+ if (deleted) await this.actionMutation('items.delete', { key }, { operation: 'delete' });
434
483
  return deleted;
435
484
  }
436
485
 
@@ -442,26 +491,17 @@ export class ItemsService extends BaseService {
442
491
  const schema = await this.getCollectionSchema();
443
492
  const permission = await this.resolvePermission('delete');
444
493
  const accessSchema = schemaForFields(schema, permission.fields);
445
- const filtered = await this.filterMutation('items.delete', { filter: filterInput }, {
446
- operation: 'delete',
447
- bulk: true,
448
- });
494
+ const filtered = await this.filterMutation('items.delete', { filter: filterInput }, { operation: 'delete', bulk: true });
449
495
  const effectiveFilter = filtered?.filter ?? filterInput;
450
496
  if (!effectiveFilter || typeof effectiveFilter !== 'object' || Array.isArray(effectiveFilter) || Object.keys(effectiveFilter).length === 0) {
451
497
  throw serviceError('FILTER_REQUIRED', 'deleteMany hook result must preserve a non-empty filter');
452
498
  }
453
499
  const filter = this.compileActionFilters(effectiveFilter, permission.filter, accessSchema, schema);
454
500
  const table = quoteIdentifier(this.collection, 'collection name');
455
- const [result] = await this.database.query(
456
- `DELETE FROM ${table}${filter.sql}`,
457
- filter.params,
458
- );
459
- await this.actionMutation('items.delete', {
460
- filter: effectiveFilter,
461
- affected: result.affectedRows,
462
- }, { operation: 'delete', bulk: true });
501
+ const [result] = await this.database.query(`DELETE FROM ${table}${filter.sql}`, filter.params);
502
+ await this.actionMutation('items.delete', { filter: effectiveFilter, affected: result.affectedRows }, { operation: 'delete', bulk: true });
463
503
  return result.affectedRows;
464
504
  }
465
505
  }
466
506
 
467
- export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
507
+ export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
@@ -4,7 +4,7 @@ import { assertPermissionValidationRule } from '../permission-validation.js';
4
4
  import { compileFilter } from '../query.js';
5
5
  import { SchemaCache } from '../schema.js';
6
6
  import {
7
- assertActionOnlyPermissionPayload,
7
+ assertSystemPermissionPayload,
8
8
  assertSystemResourceAction,
9
9
  isPermissionManagedSystemResource,
10
10
  } from '../system-permissions.js';
@@ -86,6 +86,15 @@ export class PermissionsService extends BaseService {
86
86
  this.schemaCache = options.schemaCache ?? defaultSchemaCache;
87
87
  }
88
88
 
89
+ async action(event, payload) {
90
+ if (!this.emitter) return;
91
+ await this.emitter.action(event, payload, {
92
+ accountability: this.accountability,
93
+ requestId: this.requestId,
94
+ collection: 'yuncms_permissions',
95
+ });
96
+ }
97
+
89
98
  async #collectionSchema(collection) {
90
99
  const snapshot = this.schema ?? await this.schemaCache.get(this.database);
91
100
  const collectionSchema = snapshot.collections?.[collection];
@@ -100,7 +109,10 @@ export class PermissionsService extends BaseService {
100
109
  async resolve(action, collection) {
101
110
  assertAction(action);
102
111
  const key = cacheKey(this.accountability, action, collection);
103
- if (this.permissionCache?.has(key)) return this.permissionCache.get(key);
112
+ if (this.permissionCache) {
113
+ const cached = await this.permissionCache.get(key);
114
+ if (cached !== undefined) return cached;
115
+ }
104
116
 
105
117
  let permission;
106
118
  if (this.accountability.admin === true || this.accountability.system === true) {
@@ -159,7 +171,7 @@ export class PermissionsService extends BaseService {
159
171
  };
160
172
  }
161
173
 
162
- this.permissionCache?.set(key, permission);
174
+ if (this.permissionCache) await this.permissionCache.set(key, permission);
163
175
  return permission;
164
176
  }
165
177
 
@@ -205,7 +217,7 @@ export class PermissionsService extends BaseService {
205
217
  throw error;
206
218
  }
207
219
  assertSystemResourceAction(collectionSchema, action);
208
- assertActionOnlyPermissionPayload(collectionSchema, input);
220
+ assertSystemPermissionPayload(collectionSchema, action, input);
209
221
  }
210
222
 
211
223
  const fields = normalizePermissionFields(input.fields ?? null, collectionSchema);
@@ -224,11 +236,6 @@ export class PermissionsService extends BaseService {
224
236
  error.code = 'ROLE_NOT_FOUND';
225
237
  throw error;
226
238
  }
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
- }
232
239
 
233
240
  const id = randomUUID();
234
241
  await this.database.query(
@@ -244,8 +251,10 @@ export class PermissionsService extends BaseService {
244
251
  validation == null ? null : JSON.stringify(validation),
245
252
  ],
246
253
  );
247
- this.permissionCache?.clear();
248
- return this.readOne(id);
254
+ if (this.permissionCache) await this.permissionCache.clear();
255
+ const permission = await this.readOne(id);
256
+ await this.action('permissions.create', { key: id, item: permission });
257
+ return permission;
249
258
  }
250
259
 
251
260
  async updateOne(id, patch = {}) {
@@ -269,7 +278,9 @@ export class PermissionsService extends BaseService {
269
278
  throw error;
270
279
  }
271
280
  const collectionSchema = await this.#collectionSchema(existing.collection);
272
- if (collectionSchema.system) assertActionOnlyPermissionPayload(collectionSchema, patch);
281
+ if (collectionSchema.system) {
282
+ assertSystemPermissionPayload(collectionSchema, existing.action, patch);
283
+ }
273
284
 
274
285
  const assignments = [];
275
286
  const params = [];
@@ -294,12 +305,20 @@ export class PermissionsService extends BaseService {
294
305
  `UPDATE yuncms_permissions SET ${assignments.join(', ')} WHERE id = ?`,
295
306
  params,
296
307
  );
297
- this.permissionCache?.clear();
298
- return this.readOne(id);
308
+ if (this.permissionCache) await this.permissionCache.clear();
309
+ const permission = await this.readOne(id);
310
+ await this.action('permissions.update', {
311
+ key: id,
312
+ before: existing,
313
+ item: permission,
314
+ changes: patch,
315
+ });
316
+ return permission;
299
317
  }
300
318
 
301
319
  async deleteOne(id) {
302
320
  assertPermissionManager(this.accountability);
321
+ const before = this.emitter ? await this.readOne(id) : null;
303
322
  const [result] = await this.database.query(
304
323
  'DELETE FROM yuncms_permissions WHERE id = ?',
305
324
  [id],
@@ -309,7 +328,8 @@ export class PermissionsService extends BaseService {
309
328
  error.code = 'PERMISSION_NOT_FOUND';
310
329
  throw error;
311
330
  }
312
- this.permissionCache?.clear();
331
+ if (this.permissionCache) await this.permissionCache.clear();
332
+ await this.action('permissions.delete', { key: id, before });
313
333
  return true;
314
334
  }
315
335
  }
@@ -3,13 +3,6 @@ import { randomUUID } from 'node:crypto';
3
3
  import { BaseService } from './base-service.js';
4
4
  import { resolveSystemResourceAccess } from './system-resource-access.js';
5
5
 
6
- function assertRoleManager(accountability) {
7
- if (accountability.admin === true || accountability.system === true) return;
8
- const error = new Error('Role management requires administrator accountability');
9
- error.code = 'FORBIDDEN';
10
- throw error;
11
- }
12
-
13
6
  function normalizeRoleName(name) {
14
7
  if (!name || typeof name !== 'string' || name.trim().length === 0) {
15
8
  const error = new Error('Role name is required');
@@ -25,7 +18,36 @@ function normalizeRoleName(name) {
25
18
  return normalized;
26
19
  }
27
20
 
21
+ function assertSpecialRoleCreation(accountability, { admin = false, public: publicRole = false } = {}) {
22
+ if (accountability.admin === true || accountability.system === true) return;
23
+ if (admin || publicRole) {
24
+ const error = new Error('Delegated role managers cannot create administrator or public roles');
25
+ error.code = 'FORBIDDEN';
26
+ throw error;
27
+ }
28
+ }
29
+
28
30
  export class RolesService extends BaseService {
31
+ async action(event, payload) {
32
+ if (!this.emitter) return;
33
+ await this.emitter.action(event, payload, {
34
+ accountability: this.accountability,
35
+ requestId: this.requestId,
36
+ collection: 'yuncms_roles',
37
+ });
38
+ }
39
+
40
+ async #readOneUnsafe(id) {
41
+ const [rows] = await this.database.query(
42
+ `SELECT id, name, description, admin, public, created_at, updated_at
43
+ FROM yuncms_roles
44
+ WHERE id = ?
45
+ LIMIT 1`,
46
+ [id],
47
+ );
48
+ return rows[0] ?? null;
49
+ }
50
+
29
51
  async readMany() {
30
52
  await resolveSystemResourceAccess(this, 'read', 'yuncms_roles');
31
53
  const [rows] = await this.database.query(
@@ -38,18 +60,11 @@ export class RolesService extends BaseService {
38
60
 
39
61
  async readOne(id) {
40
62
  await resolveSystemResourceAccess(this, 'read', 'yuncms_roles');
41
- const [rows] = await this.database.query(
42
- `SELECT id, name, description, admin, public, created_at, updated_at
43
- FROM yuncms_roles
44
- WHERE id = ?
45
- LIMIT 1`,
46
- [id],
47
- );
48
- return rows[0] ?? null;
63
+ return this.#readOneUnsafe(id);
49
64
  }
50
65
 
51
66
  async createOne(input = {}) {
52
- assertRoleManager(this.accountability);
67
+ await resolveSystemResourceAccess(this, 'create', 'yuncms_roles');
53
68
  const name = normalizeRoleName(input.name);
54
69
 
55
70
  const admin = input.admin === true;
@@ -59,6 +74,7 @@ export class RolesService extends BaseService {
59
74
  error.code = 'INVALID_ROLE';
60
75
  throw error;
61
76
  }
77
+ assertSpecialRoleCreation(this.accountability, { admin, public: publicRole });
62
78
 
63
79
  if (publicRole) {
64
80
  const [rows] = await this.database.query(
@@ -83,11 +99,13 @@ export class RolesService extends BaseService {
83
99
  publicRole ? 1 : 0,
84
100
  ],
85
101
  );
86
- return this.readOne(id);
102
+ const role = await this.#readOneUnsafe(id);
103
+ await this.action('roles.create', { key: id, item: role });
104
+ return role;
87
105
  }
88
106
 
89
107
  async updateOne(id, patch = {}) {
90
- assertRoleManager(this.accountability);
108
+ await resolveSystemResourceAccess(this, 'update', 'yuncms_roles');
91
109
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
92
110
  const error = new Error('Role patch must be an object');
93
111
  error.code = 'INVALID_PAYLOAD';
@@ -100,7 +118,7 @@ export class RolesService extends BaseService {
100
118
  throw error;
101
119
  }
102
120
 
103
- const existing = await this.readOne(id);
121
+ const existing = await this.#readOneUnsafe(id);
104
122
  if (!existing) {
105
123
  const error = new Error(`Unknown role: ${id}`);
106
124
  error.code = 'ROLE_NOT_FOUND';
@@ -123,12 +141,14 @@ export class RolesService extends BaseService {
123
141
  `UPDATE yuncms_roles SET ${assignments.join(', ')} WHERE id = ?`,
124
142
  params,
125
143
  );
126
- return this.readOne(id);
144
+ const role = await this.#readOneUnsafe(id);
145
+ await this.action('roles.update', { key: id, item: role, before: existing, changes: patch });
146
+ return role;
127
147
  }
128
148
 
129
149
  async deleteOne(id) {
130
- assertRoleManager(this.accountability);
131
- const role = await this.readOne(id);
150
+ await resolveSystemResourceAccess(this, 'delete', 'yuncms_roles');
151
+ const role = await this.#readOneUnsafe(id);
132
152
  if (!role) {
133
153
  const error = new Error(`Unknown role: ${id}`);
134
154
  error.code = 'ROLE_NOT_FOUND';
@@ -156,6 +176,7 @@ export class RolesService extends BaseService {
156
176
  error.code = 'ROLE_NOT_FOUND';
157
177
  throw error;
158
178
  }
179
+ await this.action('roles.delete', { key: id, before: role });
159
180
  return true;
160
181
  }
161
- }
182
+ }