@yunsoft/yuncms-core 0.1.2 → 0.1.5

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,13 +3,14 @@ import { randomUUID } from 'node:crypto';
3
3
  import { withAdvisoryLock } from '../advisory-lock.js';
4
4
  import { assertIdentifier, quoteIdentifier } from '../identifier.js';
5
5
  import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
6
+ import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
6
7
  import { incrementSchemaVersion } from '../schema-version.js';
7
8
  import { compileCollectionSystemFields, normalizeCollectionSystemFields } from '../system-fields.js';
8
9
  import { withConnectionTransaction } from '../transaction.js';
9
10
  import { BaseService } from './base-service.js';
10
11
  import { assertSchemaManager } from './schema-access.js';
11
12
 
12
- const COLLECTION_METADATA_KEYS = new Set(['note', 'singleton', 'hidden', 'metadata']);
13
+ const COLLECTION_METADATA_KEYS = new Set(['name', 'note', 'singleton', 'hidden', 'metadata']);
13
14
 
14
15
  function assertUserCollectionName(collection) {
15
16
  assertIdentifier(collection, 'collection name');
@@ -47,6 +48,9 @@ function assertCollectionMetadataPatch(patch) {
47
48
  throw invalidSchemaPayload(`Collection ${key} must be a boolean`);
48
49
  }
49
50
  }
51
+ if (Object.hasOwn(patch, 'name')) {
52
+ patch.name = normalizeDisplayName(patch.name);
53
+ }
50
54
  if (Object.hasOwn(patch, 'note') && patch.note != null && typeof patch.note !== 'string') {
51
55
  throw invalidSchemaPayload('Collection note must be a string or null');
52
56
  }
@@ -93,7 +97,13 @@ export class CollectionsService extends BaseService {
93
97
  async createOne(input = {}) {
94
98
  assertSchemaManager(this.accountability);
95
99
  assertCollectionCreateMetadata(input);
96
- const collection = assertUserCollectionName(input.collection);
100
+ const resolvedName = resolveSchemaName({
101
+ displayName: input.name ?? input.collection,
102
+ key: input.collection,
103
+ prefix: 'collection',
104
+ });
105
+ const collection = assertUserCollectionName(resolvedName.key);
106
+ const name = resolvedName.name;
97
107
  const primaryKey = input.primaryKey ?? 'id';
98
108
 
99
109
  if (primaryKey !== 'id') {
@@ -132,6 +142,7 @@ export class CollectionsService extends BaseService {
132
142
  return await withConnectionTransaction(connection, async () => {
133
143
  const created = await metadata.createCollection({
134
144
  collection,
145
+ name,
135
146
  primaryKey,
136
147
  note: input.note ?? null,
137
148
  singleton: input.singleton === true,
@@ -145,6 +156,7 @@ export class CollectionsService extends BaseService {
145
156
  await metadata.createField({
146
157
  collection,
147
158
  field: 'id',
159
+ name: 'ID',
148
160
  type: 'uuid',
149
161
  required: true,
150
162
  readonly: true,
@@ -11,6 +11,7 @@ import { PermissionsService } from './permissions-service.js';
11
11
  import { RelationsService } from './relations-service.js';
12
12
  import { RolesService } from './roles-service.js';
13
13
  import { StudioSettingsService } from './studio-settings-service.js';
14
+ import { SystemCollectionFieldsService } from './system-collection-fields-service.js';
14
15
  import { UsersService } from './users-service.js';
15
16
  import { createServiceRegistry } from './service-registry.js';
16
17
 
@@ -23,6 +24,7 @@ export function createCoreServiceRegistry() {
23
24
  ItemsService,
24
25
  CollectionsService,
25
26
  FieldsService,
27
+ SystemCollectionFieldsService,
26
28
  RelationsService,
27
29
  UsersService,
28
30
  RolesService,
@@ -4,12 +4,13 @@ import { withAdvisoryLock } from '../advisory-lock.js';
4
4
  import { compileFieldColumn } from '../field-types.js';
5
5
  import { assertIdentifier, quoteIdentifier } from '../identifier.js';
6
6
  import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
7
+ import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
7
8
  import { incrementSchemaVersion } from '../schema-version.js';
8
9
  import { withConnectionTransaction } from '../transaction.js';
9
10
  import { BaseService } from './base-service.js';
10
11
  import { assertSchemaManager } from './schema-access.js';
11
12
 
12
- const FIELD_METADATA_KEYS = new Set(['readonly', 'hidden', 'sort', 'interface', 'options']);
13
+ const FIELD_METADATA_KEYS = new Set(['name', 'readonly', 'hidden', 'sort', 'interface', 'options']);
13
14
  const FIELD_PHYSICAL_KEYS = new Set([
14
15
  'required',
15
16
  'defaultValue',
@@ -46,6 +47,7 @@ function assertFieldMetadataPatch(patch) {
46
47
  if (Object.keys(patch).length === 0) {
47
48
  throw invalidSchemaPayload('Field metadata patch cannot be empty');
48
49
  }
50
+ if (Object.hasOwn(patch, 'name')) patch.name = normalizeDisplayName(patch.name);
49
51
  }
50
52
 
51
53
  function assertPhysicalPatch(patch) {
@@ -150,7 +152,13 @@ export class FieldsService extends BaseService {
150
152
  async createOne(collection, input = {}) {
151
153
  assertSchemaManager(this.accountability);
152
154
  assertIdentifier(collection, 'collection name');
153
- const field = assertFieldName(input.field);
155
+ const resolvedName = resolveSchemaName({
156
+ displayName: input.name ?? input.field,
157
+ key: input.field,
158
+ prefix: 'field',
159
+ });
160
+ const field = assertFieldName(resolvedName.key);
161
+ const name = resolvedName.name;
154
162
 
155
163
  if (field === 'id') {
156
164
  const error = new Error('The id field is created with the collection and cannot be added again');
@@ -196,6 +204,7 @@ export class FieldsService extends BaseService {
196
204
  const created = await metadata.createField({
197
205
  collection,
198
206
  field,
207
+ name,
199
208
  type: input.type,
200
209
  required: input.required === true,
201
210
  readonly: input.readonly === true,
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import { compileFilter } from '../query.js';
3
4
  import { BaseService } from './base-service.js';
4
5
  import { resolveSystemResourceAccess } from './system-resource-access.js';
5
6
 
@@ -27,6 +28,38 @@ function normalizeMimeType(value) {
27
28
  return mimetype;
28
29
  }
29
30
 
31
+ function startsWithBytes(buffer, bytes, offset = 0) {
32
+ if (buffer.byteLength < offset + bytes.length) return false;
33
+ return bytes.every((byte, index) => buffer[offset + index] === byte);
34
+ }
35
+
36
+ function hasKnownMimeSignature(contents, mimetype) {
37
+ const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
38
+ switch (mimetype) {
39
+ case 'application/pdf':
40
+ return startsWithBytes(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]);
41
+ case 'image/png':
42
+ return startsWithBytes(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
43
+ case 'image/jpeg':
44
+ return startsWithBytes(buffer, [0xff, 0xd8, 0xff]);
45
+ case 'image/gif':
46
+ return buffer.subarray(0, 6).toString('ascii') === 'GIF87a'
47
+ || buffer.subarray(0, 6).toString('ascii') === 'GIF89a';
48
+ case 'image/webp':
49
+ return buffer.subarray(0, 4).toString('ascii') === 'RIFF'
50
+ && buffer.subarray(8, 12).toString('ascii') === 'WEBP';
51
+ default:
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function assertMimeSignature(contents, mimetype) {
57
+ const matches = hasKnownMimeSignature(contents, mimetype);
58
+ if (matches === false) {
59
+ throw fileError('FILE_MIME_MISMATCH', `File contents do not match declared MIME type: ${mimetype}`);
60
+ }
61
+ }
62
+
30
63
  function decodeJson(value) {
31
64
  if (value == null || typeof value === 'object') return value ?? null;
32
65
  try {
@@ -56,6 +89,31 @@ export class FilesService extends BaseService {
56
89
  });
57
90
  }
58
91
 
92
+ #compileReadScope(permission, id = null) {
93
+ let sql = '';
94
+ let params = [];
95
+
96
+ if (permission?.filter) {
97
+ const collectionSchema = this.schema?.collections?.yuncms_files;
98
+ if (!collectionSchema) {
99
+ throw fileError(
100
+ 'SYSTEM_SCHEMA_REQUIRED',
101
+ 'System schema is required to enforce a filtered Files permission',
102
+ );
103
+ }
104
+ const compiled = compileFilter(permission.filter, collectionSchema);
105
+ sql = compiled.sql;
106
+ params = [...compiled.params];
107
+ }
108
+
109
+ if (id != null) {
110
+ sql = sql ? `${sql} AND id = ?` : ' WHERE id = ?';
111
+ params.push(id);
112
+ }
113
+
114
+ return { sql, params };
115
+ }
116
+
59
117
  async #readOneUnsafe(id) {
60
118
  const [rows] = await this.database.query(
61
119
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
@@ -68,20 +126,34 @@ export class FilesService extends BaseService {
68
126
  return normalizeRow(rows[0]);
69
127
  }
70
128
 
129
+ async #readOneAuthorized(id, permission) {
130
+ const scope = this.#compileReadScope(permission, id);
131
+ const [rows] = await this.database.query(
132
+ `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
133
+ uploaded_by, uploaded_at, metadata
134
+ FROM yuncms_files${scope.sql}
135
+ LIMIT 1`,
136
+ scope.params,
137
+ );
138
+ return normalizeRow(rows[0]);
139
+ }
140
+
71
141
  async readMany() {
72
- await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
142
+ const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
143
+ const scope = this.#compileReadScope(permission);
73
144
  const [rows] = await this.database.query(
74
145
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
75
146
  uploaded_by, uploaded_at, metadata
76
- FROM yuncms_files
147
+ FROM yuncms_files${scope.sql}
77
148
  ORDER BY uploaded_at DESC, id DESC`,
149
+ scope.params,
78
150
  );
79
151
  return rows.map(normalizeRow);
80
152
  }
81
153
 
82
154
  async readOne(id) {
83
- await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
84
- return this.#readOneUnsafe(id);
155
+ const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
156
+ return this.#readOneAuthorized(id, permission);
85
157
  }
86
158
 
87
159
  async createOne({
@@ -100,6 +172,7 @@ export class FilesService extends BaseService {
100
172
 
101
173
  const filename = normalizeFilename(filenameDownload);
102
174
  const normalizedMime = normalizeMimeType(mimetype);
175
+ assertMimeSignature(contents, normalizedMime);
103
176
  const driver = this.storage.get(storage);
104
177
  const id = randomUUID();
105
178
  const filenameDisk = id;
@@ -144,8 +217,8 @@ export class FilesService extends BaseService {
144
217
  }
145
218
 
146
219
  async readContent(id) {
147
- await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
148
- const file = await this.#readOneUnsafe(id);
220
+ const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
221
+ const file = await this.#readOneAuthorized(id, permission);
149
222
  if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
150
223
  const driver = this.storage.get(file.storage);
151
224
  const contents = await driver.get(file.filename_disk);
@@ -225,4 +298,9 @@ export class FilesService extends BaseService {
225
298
  }
226
299
  }
227
300
 
228
- export { normalizeFilename, normalizeMimeType };
301
+ export {
302
+ assertMimeSignature,
303
+ hasKnownMimeSignature,
304
+ normalizeFilename,
305
+ normalizeMimeType,
306
+ };
@@ -7,6 +7,7 @@ import {
7
7
  compileSelectFields,
8
8
  compileSort,
9
9
  parseItemsQuery,
10
+ QUERY_LIMITS,
10
11
  } from '../query.js';
11
12
  import { SchemaCache } from '../schema.js';
12
13
  import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
@@ -215,6 +216,58 @@ export class ItemsService extends BaseService {
215
216
  };
216
217
  }
217
218
 
219
+ async readManyForRelation({ fields = null, lookupField, values = [] } = {}) {
220
+ const schema = await this.getCollectionSchema();
221
+ const trustedLookupField = assertIdentifier(lookupField, 'relation lookup field');
222
+ if (!schema.fields[trustedLookupField]) {
223
+ throw serviceError(
224
+ 'INVALID_QUERY',
225
+ `Unknown relation lookup field: ${trustedLookupField}`,
226
+ trustedLookupField,
227
+ );
228
+ }
229
+ if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxLimit) {
230
+ throw serviceError(
231
+ 'INVALID_QUERY',
232
+ `Relation lookup values must contain between 1 and ${QUERY_LIMITS.maxLimit} entries`,
233
+ trustedLookupField,
234
+ );
235
+ }
236
+
237
+ const permission = await this.resolvePermission('read');
238
+ const accessSchema = schemaForFields(schema, permission.fields);
239
+ const visibleSelection = compileSelectFields(normalizeFields(fields), accessSchema);
240
+ const internalSchema = {
241
+ ...accessSchema,
242
+ fields: {
243
+ ...accessSchema.fields,
244
+ [trustedLookupField]: schema.fields[trustedLookupField],
245
+ },
246
+ };
247
+ const internalSelection = compileSelectFields(
248
+ [...visibleSelection.fields, trustedLookupField],
249
+ internalSchema,
250
+ );
251
+ const permissionFilter = compileFilter(permission.filter, schema);
252
+ const table = quoteIdentifier(this.collection, 'collection name');
253
+ const data = [];
254
+
255
+ for (let offset = 0; offset < values.length; offset += QUERY_LIMITS.maxInValues) {
256
+ const chunk = values.slice(offset, offset + QUERY_LIMITS.maxInValues);
257
+ const filter = combineCompiledFilters(
258
+ permissionFilter,
259
+ compileFilter({ [trustedLookupField]: { _in: chunk } }, schema),
260
+ );
261
+ const [rows] = await this.database.query(
262
+ `SELECT ${internalSelection.sql} FROM ${table}${filter.sql} LIMIT ?`,
263
+ [...filter.params, chunk.length],
264
+ );
265
+ data.push(...rows);
266
+ }
267
+
268
+ return { data, visibleFields: visibleSelection.fields };
269
+ }
270
+
218
271
  async readOne(id, { fields = null } = {}) {
219
272
  const schema = await this.getCollectionSchema();
220
273
  const permission = await this.resolvePermission('read');
@@ -464,4 +517,4 @@ export class ItemsService extends BaseService {
464
517
  }
465
518
  }
466
519
 
467
- export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
520
+ 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
+ }