@yunsoft/yuncms-core 0.1.1 → 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.
@@ -10,7 +10,15 @@ import { BaseService } from './base-service.js';
10
10
  import { assertSchemaManager } from './schema-access.js';
11
11
 
12
12
  const FIELD_METADATA_KEYS = new Set(['readonly', 'hidden', 'sort', 'interface', 'options']);
13
- const FIELD_PHYSICAL_KEYS = new Set(['required', 'defaultValue', 'removeDefault', 'indexed']);
13
+ const FIELD_PHYSICAL_KEYS = new Set([
14
+ 'required',
15
+ 'defaultValue',
16
+ 'removeDefault',
17
+ 'defaultPreset',
18
+ 'removeDefaultPreset',
19
+ 'autoUpdate',
20
+ 'indexed',
21
+ ]);
14
22
 
15
23
  function assertFieldName(field) {
16
24
  assertIdentifier(field, 'field name');
@@ -18,11 +26,15 @@ function assertFieldName(field) {
18
26
  return field;
19
27
  }
20
28
 
29
+ function invalidSchemaPayload(message) {
30
+ const error = new Error(message);
31
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
32
+ return error;
33
+ }
34
+
21
35
  function assertFieldMetadataPatch(patch) {
22
36
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
23
- const error = new Error('Field metadata patch must be an object');
24
- error.code = 'INVALID_SCHEMA_PAYLOAD';
25
- throw error;
37
+ throw invalidSchemaPayload('Field metadata patch must be an object');
26
38
  }
27
39
  for (const key of Object.keys(patch)) {
28
40
  if (!FIELD_METADATA_KEYS.has(key)) {
@@ -32,23 +44,17 @@ function assertFieldMetadataPatch(patch) {
32
44
  }
33
45
  }
34
46
  if (Object.keys(patch).length === 0) {
35
- const error = new Error('Field metadata patch cannot be empty');
36
- error.code = 'INVALID_SCHEMA_PAYLOAD';
37
- throw error;
47
+ throw invalidSchemaPayload('Field metadata patch cannot be empty');
38
48
  }
39
49
  }
40
50
 
41
51
  function assertPhysicalPatch(patch) {
42
52
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
43
- const error = new Error('Physical field patch must be an object');
44
- error.code = 'INVALID_SCHEMA_PAYLOAD';
45
- throw error;
53
+ throw invalidSchemaPayload('Physical field patch must be an object');
46
54
  }
47
55
  const keys = Object.keys(patch);
48
56
  if (keys.length === 0) {
49
- const error = new Error('Physical field patch cannot be empty');
50
- error.code = 'INVALID_SCHEMA_PAYLOAD';
51
- throw error;
57
+ throw invalidSchemaPayload('Physical field patch cannot be empty');
52
58
  }
53
59
  for (const key of keys) {
54
60
  if (!FIELD_PHYSICAL_KEYS.has(key)) {
@@ -58,24 +64,30 @@ function assertPhysicalPatch(patch) {
58
64
  }
59
65
  }
60
66
  if (Object.hasOwn(patch, 'required') && typeof patch.required !== 'boolean') {
61
- const error = new Error('required must be boolean');
62
- error.code = 'INVALID_SCHEMA_PAYLOAD';
63
- throw error;
67
+ throw invalidSchemaPayload('required must be boolean');
64
68
  }
65
69
  if (Object.hasOwn(patch, 'indexed') && typeof patch.indexed !== 'boolean') {
66
- const error = new Error('indexed must be boolean');
67
- error.code = 'INVALID_SCHEMA_PAYLOAD';
68
- throw error;
70
+ throw invalidSchemaPayload('indexed must be boolean');
69
71
  }
70
- if (Object.hasOwn(patch, 'removeDefault') && patch.removeDefault !== true) {
71
- const error = new Error('removeDefault must be true when provided');
72
- error.code = 'INVALID_SCHEMA_PAYLOAD';
73
- throw error;
72
+ if (Object.hasOwn(patch, 'autoUpdate') && typeof patch.autoUpdate !== 'boolean') {
73
+ throw invalidSchemaPayload('autoUpdate must be boolean');
74
+ }
75
+ for (const key of ['removeDefault', 'removeDefaultPreset']) {
76
+ if (Object.hasOwn(patch, key) && patch[key] !== true) {
77
+ throw invalidSchemaPayload(`${key} must be true when provided`);
78
+ }
79
+ }
80
+ if (Object.hasOwn(patch, 'defaultPreset') && patch.defaultPreset !== 'now') {
81
+ throw invalidSchemaPayload('defaultPreset currently supports only now');
82
+ }
83
+ if (Object.hasOwn(patch, 'defaultValue') && Object.hasOwn(patch, 'defaultPreset')) {
84
+ throw invalidSchemaPayload('defaultValue and defaultPreset cannot be used together');
74
85
  }
75
86
  if (Object.hasOwn(patch, 'defaultValue') && patch.removeDefault === true) {
76
- const error = new Error('defaultValue and removeDefault cannot be used together');
77
- error.code = 'INVALID_SCHEMA_PAYLOAD';
78
- throw error;
87
+ throw invalidSchemaPayload('defaultValue and removeDefault cannot be used together');
88
+ }
89
+ if (Object.hasOwn(patch, 'defaultPreset') && patch.removeDefaultPreset === true) {
90
+ throw invalidSchemaPayload('defaultPreset and removeDefaultPreset cannot be used together');
79
91
  }
80
92
  }
81
93
 
@@ -89,15 +101,26 @@ function parseSchemaMetadata(value) {
89
101
  }
90
102
  }
91
103
 
104
+ function assertFieldNotSystemManaged(field) {
105
+ if (parseSchemaMetadata(field?.schema_metadata).systemManaged === true) {
106
+ const error = new Error(`System-managed field cannot be changed directly: ${field.collection}.${field.field}`);
107
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
108
+ throw error;
109
+ }
110
+ }
111
+
92
112
  function fieldInputFromMetadata(field, schemaMetadata) {
93
113
  const input = {
94
114
  type: field.type,
95
115
  required: Boolean(field.required),
116
+ interface: field.interface ?? null,
96
117
  };
97
118
  if (schemaMetadata.length !== undefined && field.type === 'string') input.length = schemaMetadata.length;
98
119
  if (schemaMetadata.precision !== undefined && field.type === 'decimal') input.precision = schemaMetadata.precision;
99
120
  if (schemaMetadata.scale !== undefined && field.type === 'decimal') input.scale = schemaMetadata.scale;
100
121
  if (Object.hasOwn(schemaMetadata, 'defaultValue')) input.defaultValue = schemaMetadata.defaultValue;
122
+ if (schemaMetadata.defaultPreset !== undefined) input.defaultPreset = schemaMetadata.defaultPreset;
123
+ if (schemaMetadata.autoUpdate === true) input.autoUpdate = true;
101
124
  return input;
102
125
  }
103
126
 
@@ -246,6 +269,7 @@ export class FieldsService extends BaseService {
246
269
  error.code = 'FIELD_NOT_FOUND';
247
270
  throw error;
248
271
  }
272
+ assertFieldNotSystemManaged(existing);
249
273
 
250
274
  return withConnectionTransaction(connection, async () => {
251
275
  const updated = await metadata.updateFieldMetadata(collection, field, patch);
@@ -288,6 +312,7 @@ export class FieldsService extends BaseService {
288
312
  error.code = 'FIELD_NOT_FOUND';
289
313
  throw error;
290
314
  }
315
+ assertFieldNotSystemManaged(existing);
291
316
 
292
317
  const relation = relations.find((candidate) =>
293
318
  candidate.many_collection === collection && candidate.many_field === field);
@@ -301,8 +326,18 @@ export class FieldsService extends BaseService {
301
326
  const currentMetadata = parseSchemaMetadata(existing.schema_metadata);
302
327
  const currentInput = fieldInputFromMetadata(existing, currentMetadata);
303
328
  const nextInput = { ...currentInput, required: nextRequired };
329
+
304
330
  if (patch.removeDefault === true) delete nextInput.defaultValue;
305
- else if (Object.hasOwn(patch, 'defaultValue')) nextInput.defaultValue = patch.defaultValue;
331
+ if (patch.removeDefaultPreset === true) delete nextInput.defaultPreset;
332
+ if (Object.hasOwn(patch, 'defaultValue')) {
333
+ nextInput.defaultValue = patch.defaultValue;
334
+ delete nextInput.defaultPreset;
335
+ }
336
+ if (Object.hasOwn(patch, 'defaultPreset')) {
337
+ nextInput.defaultPreset = patch.defaultPreset;
338
+ delete nextInput.defaultValue;
339
+ }
340
+ if (Object.hasOwn(patch, 'autoUpdate')) nextInput.autoUpdate = patch.autoUpdate;
306
341
 
307
342
  const currentCompiled = compileFieldColumn(currentInput);
308
343
  const nextCompiled = compileFieldColumn(nextInput);
@@ -410,6 +445,7 @@ export class FieldsService extends BaseService {
410
445
  error.code = 'FIELD_NOT_FOUND';
411
446
  throw error;
412
447
  }
448
+ assertFieldNotSystemManaged(existing);
413
449
 
414
450
  const blockingRelation = relations.find((relation) =>
415
451
  (relation.many_collection === collection && relation.many_field === field) ||
@@ -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 fileError(code, message) {
6
7
  const error = new Error(message);
@@ -8,11 +9,6 @@ function fileError(code, message) {
8
9
  return error;
9
10
  }
10
11
 
11
- function assertFileManager(accountability) {
12
- if (accountability.admin === true || accountability.system === true) return;
13
- throw fileError('FORBIDDEN', 'File management requires administrator accountability in V1');
14
- }
15
-
16
12
  function normalizeFilename(value) {
17
13
  if (typeof value !== 'string') throw fileError('INVALID_PAYLOAD', 'Download filename is required');
18
14
  const filename = value.trim();
@@ -60,28 +56,32 @@ export class FilesService extends BaseService {
60
56
  });
61
57
  }
62
58
 
63
- async readMany() {
64
- assertFileManager(this.accountability);
59
+ async #readOneUnsafe(id) {
65
60
  const [rows] = await this.database.query(
66
61
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
67
62
  uploaded_by, uploaded_at, metadata
68
63
  FROM yuncms_files
69
- ORDER BY uploaded_at DESC, id DESC`,
64
+ WHERE id = ?
65
+ LIMIT 1`,
66
+ [id],
70
67
  );
71
- return rows.map(normalizeRow);
68
+ return normalizeRow(rows[0]);
72
69
  }
73
70
 
74
- async readOne(id) {
75
- assertFileManager(this.accountability);
71
+ async readMany() {
72
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
76
73
  const [rows] = await this.database.query(
77
74
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
78
75
  uploaded_by, uploaded_at, metadata
79
76
  FROM yuncms_files
80
- WHERE id = ?
81
- LIMIT 1`,
82
- [id],
77
+ ORDER BY uploaded_at DESC, id DESC`,
83
78
  );
84
- return normalizeRow(rows[0]);
79
+ return rows.map(normalizeRow);
80
+ }
81
+
82
+ async readOne(id) {
83
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
84
+ return this.#readOneUnsafe(id);
85
85
  }
86
86
 
87
87
  async createOne({
@@ -92,7 +92,7 @@ export class FilesService extends BaseService {
92
92
  storage = 'local',
93
93
  metadata = null,
94
94
  } = {}) {
95
- assertFileManager(this.accountability);
95
+ await resolveSystemResourceAccess(this, 'create', 'yuncms_files');
96
96
  if (!Buffer.isBuffer(contents) && !(contents instanceof Uint8Array)) {
97
97
  throw fileError('INVALID_FILE_CONTENT', 'File contents must be Buffer or Uint8Array');
98
98
  }
@@ -124,7 +124,7 @@ export class FilesService extends BaseService {
124
124
  metadata == null ? null : JSON.stringify(metadata),
125
125
  ],
126
126
  );
127
- const file = await this.readOne(id);
127
+ const file = await this.#readOneUnsafe(id);
128
128
  await this.action('files.create', {
129
129
  key: id,
130
130
  item: file,
@@ -144,7 +144,8 @@ export class FilesService extends BaseService {
144
144
  }
145
145
 
146
146
  async readContent(id) {
147
- const file = await this.readOne(id);
147
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
148
+ const file = await this.#readOneUnsafe(id);
148
149
  if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
149
150
  const driver = this.storage.get(file.storage);
150
151
  const contents = await driver.get(file.filename_disk);
@@ -152,7 +153,7 @@ export class FilesService extends BaseService {
152
153
  }
153
154
 
154
155
  async updateOne(id, patch = {}) {
155
- assertFileManager(this.accountability);
156
+ await resolveSystemResourceAccess(this, 'update', 'yuncms_files');
156
157
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
157
158
  throw fileError('INVALID_PAYLOAD', 'File metadata patch must be an object');
158
159
  }
@@ -161,7 +162,7 @@ export class FilesService extends BaseService {
161
162
  throw fileError('INVALID_PAYLOAD', 'File update supports filenameDownload, title and metadata only');
162
163
  }
163
164
 
164
- const before = await this.readOne(id);
165
+ const before = await this.#readOneUnsafe(id);
165
166
  if (!before) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
166
167
 
167
168
  const assignments = [];
@@ -185,7 +186,7 @@ export class FilesService extends BaseService {
185
186
  params,
186
187
  );
187
188
  if (result.affectedRows !== 1) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
188
- const file = await this.readOne(id);
189
+ const file = await this.#readOneUnsafe(id);
189
190
  await this.action('files.update', {
190
191
  key: id,
191
192
  before,
@@ -196,8 +197,8 @@ export class FilesService extends BaseService {
196
197
  }
197
198
 
198
199
  async deleteOne(id) {
199
- assertFileManager(this.accountability);
200
- const file = await this.readOne(id);
200
+ await resolveSystemResourceAccess(this, 'delete', 'yuncms_files');
201
+ const file = await this.#readOneUnsafe(id);
201
202
  if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
202
203
 
203
204
  const [result] = await this.database.query('DELETE FROM yuncms_files WHERE id = ?', [id]);
@@ -9,6 +9,7 @@ import {
9
9
  parseItemsQuery,
10
10
  } from '../query.js';
11
11
  import { SchemaCache } from '../schema.js';
12
+ import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
12
13
  import { withTransaction } from '../transaction.js';
13
14
  import { BaseService } from './base-service.js';
14
15
  import { PermissionsService } from './permissions-service.js';
@@ -74,6 +75,10 @@ function createCandidateRecord(schema, id, entries) {
74
75
  continue;
75
76
  }
76
77
  const metadata = parseMetadata(fieldSchema.schema_metadata);
78
+ if (metadata.defaultPreset === 'now') {
79
+ candidate[fieldSchema.field] = new Date();
80
+ continue;
81
+ }
77
82
  candidate[fieldSchema.field] = Object.hasOwn(metadata, 'defaultValue')
78
83
  ? metadata.defaultValue
79
84
  : null;
@@ -82,6 +87,10 @@ function createCandidateRecord(schema, id, entries) {
82
87
  return candidate;
83
88
  }
84
89
 
90
+ function mergeSystemEntries(entries, schema, accountability, operation, now = new Date()) {
91
+ return [...entries, ...systemMutationEntries(schema, accountability, operation, now)];
92
+ }
93
+
85
94
  export class ItemsService extends BaseService {
86
95
  constructor(collection, options = {}) {
87
96
  super(options);
@@ -114,6 +123,12 @@ export class ItemsService extends BaseService {
114
123
  if (!collectionSchema) {
115
124
  throw serviceError('COLLECTION_NOT_FOUND', `Unknown collection: ${this.collection}`);
116
125
  }
126
+ if (collectionSchema.system) {
127
+ throw serviceError(
128
+ 'SYSTEM_COLLECTION_USE_DEDICATED_SERVICE',
129
+ `System collection ${this.collection} must be accessed through its dedicated service`,
130
+ );
131
+ }
117
132
  return collectionSchema;
118
133
  }
119
134
 
@@ -150,8 +165,9 @@ export class ItemsService extends BaseService {
150
165
  for (const fieldSchema of Object.values(schema.fields)) {
151
166
  if (!fieldSchema.required || fieldSchema.field === schema.primary_key) continue;
152
167
  if (Object.hasOwn(payload, fieldSchema.field)) continue;
168
+ if (isSystemManagedField(fieldSchema)) continue;
153
169
  const metadata = parseMetadata(fieldSchema.schema_metadata);
154
- if (Object.hasOwn(metadata, 'defaultValue')) continue;
170
+ if (Object.hasOwn(metadata, 'defaultValue') || metadata.defaultPreset != null) continue;
155
171
  throw serviceError('REQUIRED_FIELD_MISSING', `Required field is missing: ${fieldSchema.field}`, fieldSchema.field);
156
172
  }
157
173
  }
@@ -230,7 +246,8 @@ export class ItemsService extends BaseService {
230
246
  const schema = await this.getCollectionSchema();
231
247
  const permission = await this.resolvePermission('create');
232
248
  const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create' });
233
- const entries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
249
+ const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
250
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
234
251
  const id = randomUUID();
235
252
  const candidate = createCandidateRecord(schema, id, entries);
236
253
  enforcePermissionValidation(candidate, permission.validation, schema);
@@ -264,7 +281,8 @@ export class ItemsService extends BaseService {
264
281
  operation: 'create',
265
282
  bulk: true,
266
283
  });
267
- const entries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
284
+ const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
285
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
268
286
  const id = randomUUID();
269
287
  const candidate = createCandidateRecord(schema, id, entries);
270
288
  enforcePermissionValidation(candidate, permission.validation, schema);
@@ -306,8 +324,10 @@ export class ItemsService extends BaseService {
306
324
  operation: 'update',
307
325
  key: id,
308
326
  });
309
- const entries = this.validatePayload(filteredPayload, schema, permission);
310
- if (entries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
327
+ const callerEntries = this.validatePayload(filteredPayload, schema, permission);
328
+ if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
329
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
330
+ const effectiveChanges = Object.fromEntries(entries);
311
331
 
312
332
  const table = quoteIdentifier(this.collection, 'collection name');
313
333
  const filter = combineCompiledFilters(
@@ -322,7 +342,7 @@ export class ItemsService extends BaseService {
322
342
  );
323
343
  const current = currentRows[0];
324
344
  if (!current) return null;
325
- enforcePermissionValidation({ ...current, ...filteredPayload }, permission.validation, schema);
345
+ enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema);
326
346
  }
327
347
 
328
348
  const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
@@ -336,7 +356,7 @@ export class ItemsService extends BaseService {
336
356
  await this.actionMutation('items.update', {
337
357
  key: id,
338
358
  item: record,
339
- changes: filteredPayload,
359
+ changes: effectiveChanges,
340
360
  }, { operation: 'update' });
341
361
  return record;
342
362
  }
@@ -354,8 +374,10 @@ export class ItemsService extends BaseService {
354
374
  bulk: true,
355
375
  filter: filterInput,
356
376
  });
357
- const entries = this.validatePayload(filteredPayload, schema, permission);
358
- if (entries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
377
+ const callerEntries = this.validatePayload(filteredPayload, schema, permission);
378
+ if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
379
+ const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
380
+ const effectiveChanges = Object.fromEntries(entries);
359
381
  const filter = this.compileActionFilters(filterInput, permission.filter, accessSchema, schema);
360
382
  const table = quoteIdentifier(this.collection, 'collection name');
361
383
 
@@ -371,7 +393,7 @@ export class ItemsService extends BaseService {
371
393
  );
372
394
  }
373
395
  for (const row of rows) {
374
- enforcePermissionValidation({ ...row, ...filteredPayload }, permission.validation, schema);
396
+ enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
375
397
  }
376
398
  }
377
399
 
@@ -382,7 +404,7 @@ export class ItemsService extends BaseService {
382
404
  );
383
405
  await this.actionMutation('items.update', {
384
406
  filter: filterInput,
385
- changes: filteredPayload,
407
+ changes: effectiveChanges,
386
408
  affected: result.affectedRows,
387
409
  }, { operation: 'update', bulk: true });
388
410
  return result.affectedRows;
@@ -442,4 +464,4 @@ export class ItemsService extends BaseService {
442
464
  }
443
465
  }
444
466
 
445
- export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
467
+ export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
@@ -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
- if (!roleRows[0]) {
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
- assertRoleManager(this.accountability);
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
- assertRoleManager(this.accountability);
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,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
+ }