@yunsoft/yuncms-core 0.1.1 → 0.1.3

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.
@@ -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],
@@ -3,6 +3,11 @@ import { BaseService } from './base-service.js';
3
3
  const THEMES = new Set(['system', 'light', 'dark']);
4
4
  const LOCALES = new Set(['en', 'tr']);
5
5
  const ACCENT_PATTERN = /^#[0-9a-f]{6}$/i;
6
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
+ const YUNSOFT_LOGOS = new Set([
8
+ 'https://yunsoft.com/light-logo.png',
9
+ 'https://yunsoft.com/dark-logo.png',
10
+ ]);
6
11
 
7
12
  function invalid(message) {
8
13
  const error = new Error(message);
@@ -10,6 +15,12 @@ function invalid(message) {
10
15
  return error;
11
16
  }
12
17
 
18
+ function notFound(message) {
19
+ const error = new Error(message);
20
+ error.code = 'NOT_FOUND';
21
+ return error;
22
+ }
23
+
13
24
  function assertManager(accountability) {
14
25
  if (accountability.admin === true || accountability.system === true) return;
15
26
  const error = new Error('Studio settings require administrator accountability');
@@ -25,16 +36,17 @@ function normalizeBrandName(value) {
25
36
  }
26
37
 
27
38
  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');
39
+ const normalized = String(value ?? '').trim();
40
+ if (!YUNSOFT_LOGOS.has(normalized)) {
41
+ throw invalid('External logo URLs are not supported; choose an image from Files');
36
42
  }
37
- if (!['http:', 'https:'].includes(url.protocol)) throw invalid('Logo URL must use HTTP or HTTPS');
43
+ return normalized;
44
+ }
45
+
46
+ function normalizeImageFile(value, label) {
47
+ if (value == null || value === '') return null;
48
+ const normalized = String(value).trim();
49
+ if (!UUID_PATTERN.test(normalized)) throw invalid(`${label} file id must be a UUID`);
38
50
  return normalized;
39
51
  }
40
52
 
@@ -60,6 +72,8 @@ function publicSettings(row) {
60
72
  return {
61
73
  brand_name: row.brand_name,
62
74
  logo_url: row.logo_url,
75
+ logo_file: row.logo_file ?? null,
76
+ favicon_file: row.favicon_file ?? null,
63
77
  accent_color: row.accent_color,
64
78
  theme: row.theme,
65
79
  default_locale: row.default_locale,
@@ -70,7 +84,7 @@ function publicSettings(row) {
70
84
  export class StudioSettingsService extends BaseService {
71
85
  async readPublic() {
72
86
  const [rows] = await this.database.query(
73
- `SELECT brand_name, logo_url, accent_color, theme, default_locale, updated_at
87
+ `SELECT brand_name, logo_url, logo_file, favicon_file, accent_color, theme, default_locale, updated_at
74
88
  FROM yuncms_studio_settings
75
89
  WHERE id = 1
76
90
  LIMIT 1`,
@@ -89,12 +103,56 @@ export class StudioSettingsService extends BaseService {
89
103
  return this.readPublic();
90
104
  }
91
105
 
106
+ async readImageAssetContent(settingKey, label) {
107
+ const settings = await this.readPublic();
108
+ const fileId = settings[settingKey];
109
+ if (!fileId) throw notFound(`No file-backed Studio ${label.toLowerCase()} is configured`);
110
+ if (!this.storage) throw new Error(`StudioSettingsService requires storage to read a file-backed ${label.toLowerCase()}`);
111
+
112
+ const [rows] = await this.database.query(
113
+ `SELECT id, storage, filename_disk, mimetype, filesize
114
+ FROM yuncms_files
115
+ WHERE id = ?
116
+ LIMIT 1`,
117
+ [fileId],
118
+ );
119
+ const file = rows[0];
120
+ if (!file) throw notFound(`Configured Studio ${label.toLowerCase()} file does not exist`);
121
+ if (!String(file.mimetype || '').toLowerCase().startsWith('image/')) {
122
+ throw invalid(`Configured Studio ${label.toLowerCase()} must be an image`);
123
+ }
124
+
125
+ const driver = this.storage.get(file.storage);
126
+ const contents = await driver.get(file.filename_disk);
127
+ return { file, contents };
128
+ }
129
+
130
+ async readLogoContent() {
131
+ return this.readImageAssetContent('logo_file', 'Logo');
132
+ }
133
+
134
+ async readFaviconContent() {
135
+ return this.readImageAssetContent('favicon_file', 'Favicon');
136
+ }
137
+
138
+ async validateSelectedImage(fileId, label) {
139
+ if (!fileId) return;
140
+ const [files] = await this.database.query(
141
+ 'SELECT id, mimetype FROM yuncms_files WHERE id = ? LIMIT 1',
142
+ [fileId],
143
+ );
144
+ if (!files[0]) throw invalid(`Selected ${label.toLowerCase()} file does not exist`);
145
+ if (!String(files[0].mimetype || '').toLowerCase().startsWith('image/')) {
146
+ throw invalid(`Selected ${label.toLowerCase()} file must be an image`);
147
+ }
148
+ }
149
+
92
150
  async updateOne(patch = {}) {
93
151
  assertManager(this.accountability);
94
152
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid('Studio settings patch must be an object');
95
153
 
96
154
  const keys = Object.keys(patch);
97
- const allowed = new Set(['brand_name', 'logo_url', 'accent_color', 'theme', 'default_locale']);
155
+ const allowed = new Set(['brand_name', 'logo_url', 'logo_file', 'favicon_file', 'accent_color', 'theme', 'default_locale']);
98
156
  if (keys.length === 0 || keys.some((key) => !allowed.has(key))) {
99
157
  throw invalid('Studio settings patch contains unsupported properties');
100
158
  }
@@ -109,6 +167,18 @@ export class StudioSettingsService extends BaseService {
109
167
  assignments.push('logo_url = ?');
110
168
  params.push(normalizeLogoUrl(patch.logo_url));
111
169
  }
170
+ if (Object.hasOwn(patch, 'logo_file')) {
171
+ const fileId = normalizeImageFile(patch.logo_file, 'Logo');
172
+ await this.validateSelectedImage(fileId, 'Logo');
173
+ assignments.push('logo_file = ?');
174
+ params.push(fileId);
175
+ }
176
+ if (Object.hasOwn(patch, 'favicon_file')) {
177
+ const fileId = normalizeImageFile(patch.favicon_file, 'Favicon');
178
+ await this.validateSelectedImage(fileId, 'Favicon');
179
+ assignments.push('favicon_file = ?');
180
+ params.push(fileId);
181
+ }
112
182
  if (Object.hasOwn(patch, 'accent_color')) {
113
183
  assignments.push('accent_color = ?');
114
184
  params.push(normalizeAccent(patch.accent_color));
@@ -134,6 +204,8 @@ export class StudioSettingsService extends BaseService {
134
204
  export const STUDIO_SETTING_DEFAULTS = Object.freeze({
135
205
  brand_name: 'YunCMS',
136
206
  logo_url: 'https://yunsoft.com/light-logo.png',
207
+ logo_file: null,
208
+ favicon_file: null,
137
209
  accent_color: '#2563eb',
138
210
  theme: 'system',
139
211
  default_locale: 'en',
@@ -0,0 +1,122 @@
1
+ import { withAdvisoryLock } from '../advisory-lock.js';
2
+ import { compileFieldColumn } from '../field-types.js';
3
+ import { assertIdentifier, quoteIdentifier } from '../identifier.js';
4
+ import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
5
+ import { resolveSchemaName } from '../schema-key.js';
6
+ import { incrementSchemaVersion } from '../schema-version.js';
7
+ import { isPermissionManagedSystemResource } from '../system-permissions.js';
8
+ import { withConnectionTransaction } from '../transaction.js';
9
+ import { BaseService } from './base-service.js';
10
+ import { assertSchemaManager } from './schema-access.js';
11
+
12
+ function fieldName(value) {
13
+ assertIdentifier(value, 'field name');
14
+ if (value.length > 64) {
15
+ const error = new Error('Field name cannot exceed 64 characters');
16
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
17
+ throw error;
18
+ }
19
+ if (value === 'id') {
20
+ const error = new Error('The id field cannot be replaced');
21
+ error.code = 'FIELD_EXISTS';
22
+ throw error;
23
+ }
24
+ return value;
25
+ }
26
+
27
+ function assertExtensibleSystemCollection(collection) {
28
+ if (!collection?.system || !isPermissionManagedSystemResource(collection)) {
29
+ const error = new Error(`System collection is not extensible: ${collection?.collection || 'unknown'}`);
30
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ function assertSystemExtensionInput(input = {}) {
36
+ if (input.required === true) {
37
+ const error = new Error('Custom system collection fields must be optional in V1');
38
+ error.code = 'SYSTEM_EXTENSION_REQUIRED_UNSUPPORTED';
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ export class SystemCollectionFieldsService extends BaseService {
44
+ async createOne(collectionName, input = {}) {
45
+ assertSchemaManager(this.accountability);
46
+ assertIdentifier(collectionName, 'collection name');
47
+ assertSystemExtensionInput(input);
48
+ const resolvedName = resolveSchemaName({
49
+ displayName: input.name ?? input.field,
50
+ key: input.field,
51
+ prefix: 'field',
52
+ });
53
+ const field = fieldName(resolvedName.key);
54
+ const name = resolvedName.name;
55
+ const compiled = compileFieldColumn({ ...input, required: false });
56
+
57
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
58
+ const metadata = new SchemaMetadataRepository(connection);
59
+ const collection = await metadata.readCollection(collectionName);
60
+ if (!collection) {
61
+ const error = new Error(`Unknown collection: ${collectionName}`);
62
+ error.code = 'COLLECTION_NOT_FOUND';
63
+ throw error;
64
+ }
65
+ assertExtensibleSystemCollection(collection);
66
+
67
+ const existing = await metadata.readField(collectionName, field);
68
+ if (existing) {
69
+ const error = new Error(`Field already exists: ${collectionName}.${field}`);
70
+ error.code = 'FIELD_EXISTS';
71
+ throw error;
72
+ }
73
+
74
+ const table = quoteIdentifier(collectionName, 'collection name');
75
+ const column = quoteIdentifier(field, 'field name');
76
+ let physicalCreated = false;
77
+
78
+ try {
79
+ await connection.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${compiled.sql}`, compiled.params);
80
+ physicalCreated = true;
81
+
82
+ return await withConnectionTransaction(connection, async () => {
83
+ const created = await metadata.createField({
84
+ collection: collectionName,
85
+ field,
86
+ name,
87
+ type: input.type,
88
+ required: false,
89
+ readonly: input.readonly === true,
90
+ hidden: input.hidden === true,
91
+ sort: input.sort ?? null,
92
+ interface: input.interface ?? null,
93
+ options: input.options ?? null,
94
+ schemaMetadata: {
95
+ ...compiled.schemaMetadata,
96
+ systemExtension: true,
97
+ },
98
+ });
99
+ const schemaVersion = await incrementSchemaVersion(connection);
100
+ return { ...created, schemaVersion };
101
+ });
102
+ } catch (error) {
103
+ try {
104
+ await metadata.deleteField(collectionName, field);
105
+ } catch (cleanupError) {
106
+ error.cleanupMetadataError = cleanupError;
107
+ }
108
+ if (physicalCreated) {
109
+ try {
110
+ await connection.query(`ALTER TABLE ${table} DROP COLUMN ${column}`);
111
+ } catch (cleanupError) {
112
+ error.cleanupPhysicalError = cleanupError;
113
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
114
+ }
115
+ }
116
+ throw error;
117
+ }
118
+ });
119
+ }
120
+ }
121
+
122
+ export { assertExtensibleSystemCollection, assertSystemExtensionInput };
@@ -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
+ }