@yunsoft/yuncms-core 0.1.0

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -0
  3. package/package.json +36 -0
  4. package/src/accountability.js +39 -0
  5. package/src/advisory-lock.js +30 -0
  6. package/src/auth/password.js +100 -0
  7. package/src/auth/tokens.js +38 -0
  8. package/src/auth/users-repository.js +25 -0
  9. package/src/bootstrap.js +41 -0
  10. package/src/config.js +106 -0
  11. package/src/context.js +33 -0
  12. package/src/database.js +31 -0
  13. package/src/errors.js +35 -0
  14. package/src/field-types.js +110 -0
  15. package/src/hooks.js +121 -0
  16. package/src/identifier.js +13 -0
  17. package/src/index.js +67 -0
  18. package/src/logger.js +56 -0
  19. package/src/m2m-lifecycle.js +139 -0
  20. package/src/mail/smtp-mailer.js +65 -0
  21. package/src/migrations/0001-system-schema.js +164 -0
  22. package/src/migrations/0002-session-access-tokens.js +10 -0
  23. package/src/migrations/0003-public-role-constraints.js +10 -0
  24. package/src/migrations/0004-auth-action-tokens.js +19 -0
  25. package/src/migrations.js +81 -0
  26. package/src/permission-validation.js +63 -0
  27. package/src/query.js +193 -0
  28. package/src/relation-expansion.js +216 -0
  29. package/src/retry.js +29 -0
  30. package/src/schema-metadata-repository.js +284 -0
  31. package/src/schema-version.js +21 -0
  32. package/src/schema.js +82 -0
  33. package/src/services/api-tokens-service.js +117 -0
  34. package/src/services/audit-service.js +182 -0
  35. package/src/services/auth-service.js +164 -0
  36. package/src/services/auth-tokens-service.js +215 -0
  37. package/src/services/base-service.js +26 -0
  38. package/src/services/collections-service.js +249 -0
  39. package/src/services/core-services.js +32 -0
  40. package/src/services/fields-service.js +471 -0
  41. package/src/services/file-reconciliation-service.js +127 -0
  42. package/src/services/files-service.js +227 -0
  43. package/src/services/items-service.js +445 -0
  44. package/src/services/permissions-service.js +282 -0
  45. package/src/services/relations-service.js +455 -0
  46. package/src/services/roles-service.js +160 -0
  47. package/src/services/schema-access.js +7 -0
  48. package/src/services/service-registry.js +32 -0
  49. package/src/services/sessions-service.js +179 -0
  50. package/src/services/users-service.js +215 -0
  51. package/src/setup.js +66 -0
  52. package/src/storage/local-storage-driver.js +105 -0
  53. package/src/storage/s3-storage-driver.js +150 -0
  54. package/src/storage/storage-registry.js +39 -0
  55. package/src/transaction.js +46 -0
@@ -0,0 +1,455 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { withAdvisoryLock } from '../advisory-lock.js';
4
+ import { compileFieldColumn } from '../field-types.js';
5
+ import { assertIdentifier, quoteIdentifier } from '../identifier.js';
6
+ import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
7
+ import { incrementSchemaVersion } from '../schema-version.js';
8
+ import { withConnectionTransaction } from '../transaction.js';
9
+ import { BaseService } from './base-service.js';
10
+ import { assertSchemaManager } from './schema-access.js';
11
+
12
+ const ON_DELETE_ACTIONS = new Set(['RESTRICT', 'CASCADE', 'SET NULL']);
13
+
14
+ function constraintName(manyCollection, manyField, oneCollection) {
15
+ const digest = createHash('sha256')
16
+ .update(`${manyCollection}:${manyField}:${oneCollection}`)
17
+ .digest('hex')
18
+ .slice(0, 24);
19
+ return `yfk_${digest}`;
20
+ }
21
+
22
+ function assertOnDelete(value) {
23
+ const action = String(value ?? 'RESTRICT').toUpperCase();
24
+ if (!ON_DELETE_ACTIONS.has(action)) {
25
+ const error = new Error(`Unsupported ON DELETE action: ${action}`);
26
+ error.code = 'INVALID_ON_DELETE';
27
+ throw error;
28
+ }
29
+ return action;
30
+ }
31
+
32
+ function assertUserSchemaIdentifier(value, label) {
33
+ const identifier = assertIdentifier(value, label);
34
+ if (identifier.length > 64) {
35
+ const error = new Error(`${label} cannot exceed 64 characters`);
36
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
37
+ throw error;
38
+ }
39
+ if (identifier.toLowerCase().startsWith('yuncms_')) {
40
+ const error = new Error(`The yuncms_ prefix is reserved: ${identifier}`);
41
+ error.code = 'RESERVED_COLLECTION_NAME';
42
+ throw error;
43
+ }
44
+ return identifier;
45
+ }
46
+
47
+ function parseRelationMetadata(value) {
48
+ if (value == null) return {};
49
+ if (typeof value === 'object') return value;
50
+ try {
51
+ return JSON.parse(value);
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ function parseSchemaMetadata(value) {
58
+ if (value == null) return {};
59
+ if (typeof value === 'object') return value;
60
+ try {
61
+ return JSON.parse(value);
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ function fieldDefinitionFromMetadata(field, { required = true } = {}) {
68
+ const schema = parseSchemaMetadata(field.schema_metadata);
69
+ const input = { type: field.type, required };
70
+ if (field.type === 'string' && schema.length !== undefined) input.length = schema.length;
71
+ if (field.type === 'decimal') {
72
+ if (schema.precision !== undefined) input.precision = schema.precision;
73
+ if (schema.scale !== undefined) input.scale = schema.scale;
74
+ }
75
+ return compileFieldColumn(input);
76
+ }
77
+
78
+ export class RelationsService extends BaseService {
79
+ async readMany() {
80
+ assertSchemaManager(this.accountability);
81
+ return new SchemaMetadataRepository(this.database).listRelations();
82
+ }
83
+
84
+ async readOne(manyCollection, manyField) {
85
+ assertSchemaManager(this.accountability);
86
+ assertIdentifier(manyCollection, 'collection name');
87
+ assertIdentifier(manyField, 'field name');
88
+ return new SchemaMetadataRepository(this.database).readRelation(manyCollection, manyField);
89
+ }
90
+
91
+ async readO2M(oneCollection) {
92
+ assertSchemaManager(this.accountability);
93
+ assertIdentifier(oneCollection, 'one collection');
94
+ return new SchemaMetadataRepository(this.database).listRelationsForOne(oneCollection);
95
+ }
96
+
97
+ async createM2O(input = {}) {
98
+ assertSchemaManager(this.accountability);
99
+ const manyCollection = assertIdentifier(input.manyCollection, 'many collection');
100
+ const manyField = assertIdentifier(input.manyField, 'many field');
101
+ const oneCollection = assertIdentifier(input.oneCollection, 'one collection');
102
+ const onDelete = assertOnDelete(input.onDelete);
103
+
104
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
105
+ const metadata = new SchemaMetadataRepository(connection);
106
+ const [manyCollectionMetadata, oneCollectionMetadata] = await Promise.all([
107
+ metadata.readCollection(manyCollection),
108
+ metadata.readCollection(oneCollection),
109
+ ]);
110
+
111
+ if (!manyCollectionMetadata || !oneCollectionMetadata) {
112
+ const error = new Error('Both relation collections must exist');
113
+ error.code = 'COLLECTION_NOT_FOUND';
114
+ throw error;
115
+ }
116
+ if (manyCollectionMetadata.system || oneCollectionMetadata.system) {
117
+ const error = new Error('System collections cannot be changed through the dynamic relation API');
118
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
119
+ throw error;
120
+ }
121
+
122
+ const oneField = input.oneField ?? oneCollectionMetadata.primary_key;
123
+ assertIdentifier(oneField, 'one field');
124
+ if (oneField !== oneCollectionMetadata.primary_key) {
125
+ const error = new Error('V1 M2O relations must reference the target collection primary key');
126
+ error.code = 'UNSUPPORTED_RELATION_TARGET';
127
+ throw error;
128
+ }
129
+
130
+ const [manyFieldMetadata, oneFieldMetadata, existingRelation] = await Promise.all([
131
+ metadata.readField(manyCollection, manyField),
132
+ metadata.readField(oneCollection, oneField),
133
+ metadata.readRelation(manyCollection, manyField),
134
+ ]);
135
+
136
+ if (!manyFieldMetadata || !oneFieldMetadata) {
137
+ const error = new Error('Both relation fields must exist in schema metadata');
138
+ error.code = 'FIELD_NOT_FOUND';
139
+ throw error;
140
+ }
141
+ if (existingRelation) {
142
+ const error = new Error(`Relation already exists for ${manyCollection}.${manyField}`);
143
+ error.code = 'RELATION_EXISTS';
144
+ throw error;
145
+ }
146
+ if (manyFieldMetadata.type !== oneFieldMetadata.type) {
147
+ const error = new Error(
148
+ `Relation field types do not match: ${manyFieldMetadata.type} -> ${oneFieldMetadata.type}`,
149
+ );
150
+ error.code = 'RELATION_TYPE_MISMATCH';
151
+ throw error;
152
+ }
153
+ if (onDelete === 'SET NULL' && Boolean(manyFieldMetadata.required)) {
154
+ const error = new Error('SET NULL cannot be used with a required relation field');
155
+ error.code = 'INVALID_ON_DELETE';
156
+ throw error;
157
+ }
158
+
159
+ const fkName = constraintName(manyCollection, manyField, oneCollection);
160
+ const manyTableSql = quoteIdentifier(manyCollection, 'many collection');
161
+ const manyFieldSql = quoteIdentifier(manyField, 'many field');
162
+ const oneTableSql = quoteIdentifier(oneCollection, 'one collection');
163
+ const oneFieldSql = quoteIdentifier(oneField, 'one field');
164
+ const constraintSql = quoteIdentifier(fkName, 'constraint name');
165
+ let physicalRelationCreated = false;
166
+
167
+ try {
168
+ await connection.query(
169
+ `ALTER TABLE ${manyTableSql}
170
+ ADD CONSTRAINT ${constraintSql}
171
+ FOREIGN KEY (${manyFieldSql}) REFERENCES ${oneTableSql} (${oneFieldSql})
172
+ ON DELETE ${onDelete}`,
173
+ );
174
+ physicalRelationCreated = true;
175
+
176
+ return await withConnectionTransaction(connection, async () => {
177
+ const created = await metadata.createRelation({
178
+ manyCollection,
179
+ manyField,
180
+ oneCollection,
181
+ oneField,
182
+ onDelete,
183
+ metadata: { constraintName: fkName, kind: 'm2o' },
184
+ });
185
+
186
+ const schemaVersion = await incrementSchemaVersion(connection);
187
+ return { ...created, schemaVersion };
188
+ });
189
+ } catch (error) {
190
+ const cleanupErrors = [];
191
+
192
+ try {
193
+ await metadata.deleteRelation(manyCollection, manyField);
194
+ } catch (cleanupError) {
195
+ cleanupErrors.push(cleanupError);
196
+ }
197
+
198
+ if (physicalRelationCreated) {
199
+ try {
200
+ await connection.query(
201
+ `ALTER TABLE ${manyTableSql} DROP FOREIGN KEY ${constraintSql}`,
202
+ );
203
+ } catch (cleanupError) {
204
+ cleanupErrors.push(cleanupError);
205
+ }
206
+ }
207
+
208
+ if (cleanupErrors.length > 0) {
209
+ error.cleanupErrors = cleanupErrors;
210
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
211
+ }
212
+ throw error;
213
+ }
214
+ });
215
+ }
216
+
217
+ async createM2M(input = {}) {
218
+ assertSchemaManager(this.accountability);
219
+ const junctionCollection = assertUserSchemaIdentifier(input.junctionCollection, 'junction collection');
220
+ const leftCollection = assertIdentifier(input.leftCollection, 'left collection');
221
+ const rightCollection = assertIdentifier(input.rightCollection, 'right collection');
222
+ const leftField = assertUserSchemaIdentifier(input.leftField ?? `${leftCollection}_id`, 'left junction field');
223
+ const rightField = assertUserSchemaIdentifier(input.rightField ?? `${rightCollection}_id`, 'right junction field');
224
+ if (leftField === rightField) {
225
+ const error = new Error('M2M junction fields must be distinct; provide explicit leftField/rightField names');
226
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
227
+ throw error;
228
+ }
229
+
230
+ const leftOnDelete = assertOnDelete(input.leftOnDelete ?? 'CASCADE');
231
+ const rightOnDelete = assertOnDelete(input.rightOnDelete ?? 'CASCADE');
232
+ if (leftOnDelete === 'SET NULL' || rightOnDelete === 'SET NULL') {
233
+ const error = new Error('M2M junction fields are required; SET NULL is not supported');
234
+ error.code = 'INVALID_ON_DELETE';
235
+ throw error;
236
+ }
237
+
238
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
239
+ const metadata = new SchemaMetadataRepository(connection);
240
+ const [junctionExisting, leftMetadata, rightMetadata] = await Promise.all([
241
+ metadata.readCollection(junctionCollection),
242
+ metadata.readCollection(leftCollection),
243
+ metadata.readCollection(rightCollection),
244
+ ]);
245
+ if (junctionExisting) {
246
+ const error = new Error(`Junction collection already exists: ${junctionCollection}`);
247
+ error.code = 'COLLECTION_EXISTS';
248
+ throw error;
249
+ }
250
+ if (!leftMetadata || !rightMetadata) {
251
+ const error = new Error('Both M2M target collections must exist');
252
+ error.code = 'COLLECTION_NOT_FOUND';
253
+ throw error;
254
+ }
255
+ if (leftMetadata.system || rightMetadata.system) {
256
+ const error = new Error('System collections cannot participate in dynamic M2M creation');
257
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
258
+ throw error;
259
+ }
260
+
261
+ const leftPrimaryKey = leftMetadata.primary_key;
262
+ const rightPrimaryKey = rightMetadata.primary_key;
263
+ const [leftPk, rightPk] = await Promise.all([
264
+ metadata.readField(leftCollection, leftPrimaryKey),
265
+ metadata.readField(rightCollection, rightPrimaryKey),
266
+ ]);
267
+ if (!leftPk || !rightPk) {
268
+ const error = new Error('M2M target primary-key metadata is missing');
269
+ error.code = 'SCHEMA_METADATA_DRIFT';
270
+ throw error;
271
+ }
272
+
273
+ const leftColumn = fieldDefinitionFromMetadata(leftPk, { required: true });
274
+ const rightColumn = fieldDefinitionFromMetadata(rightPk, { required: true });
275
+ const junctionSql = quoteIdentifier(junctionCollection, 'junction collection');
276
+ const leftFieldSql = quoteIdentifier(leftField, 'left junction field');
277
+ const rightFieldSql = quoteIdentifier(rightField, 'right junction field');
278
+ const leftCollectionSql = quoteIdentifier(leftCollection, 'left collection');
279
+ const rightCollectionSql = quoteIdentifier(rightCollection, 'right collection');
280
+ const leftPkSql = quoteIdentifier(leftPrimaryKey, 'left primary key');
281
+ const rightPkSql = quoteIdentifier(rightPrimaryKey, 'right primary key');
282
+ const leftFk = constraintName(junctionCollection, leftField, leftCollection);
283
+ const rightFk = constraintName(junctionCollection, rightField, rightCollection);
284
+ const leftFkSql = quoteIdentifier(leftFk, 'left foreign key');
285
+ const rightFkSql = quoteIdentifier(rightFk, 'right foreign key');
286
+ const uniqueName = quoteIdentifier(
287
+ `yuq_${createHash('sha256').update(`${junctionCollection}:${leftField}:${rightField}`).digest('hex').slice(0, 24)}`,
288
+ 'junction unique index',
289
+ );
290
+ let tableCreated = false;
291
+
292
+ try {
293
+ await connection.query(
294
+ `CREATE TABLE ${junctionSql} (
295
+ id CHAR(36) NOT NULL PRIMARY KEY,
296
+ ${leftFieldSql} ${leftColumn.sql},
297
+ ${rightFieldSql} ${rightColumn.sql},
298
+ CONSTRAINT ${leftFkSql} FOREIGN KEY (${leftFieldSql})
299
+ REFERENCES ${leftCollectionSql} (${leftPkSql}) ON DELETE ${leftOnDelete},
300
+ CONSTRAINT ${rightFkSql} FOREIGN KEY (${rightFieldSql})
301
+ REFERENCES ${rightCollectionSql} (${rightPkSql}) ON DELETE ${rightOnDelete},
302
+ UNIQUE KEY ${uniqueName} (${leftFieldSql}, ${rightFieldSql})
303
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
304
+ [...leftColumn.params, ...rightColumn.params],
305
+ );
306
+ tableCreated = true;
307
+
308
+ return await withConnectionTransaction(connection, async () => {
309
+ await metadata.createCollection({
310
+ collection: junctionCollection,
311
+ primaryKey: 'id',
312
+ hidden: input.hidden !== false,
313
+ note: input.note ?? `M2M junction: ${leftCollection} <-> ${rightCollection}`,
314
+ metadata: {
315
+ junction: true,
316
+ leftCollection,
317
+ rightCollection,
318
+ },
319
+ });
320
+ await metadata.createField({
321
+ collection: junctionCollection,
322
+ field: 'id',
323
+ type: 'uuid',
324
+ required: true,
325
+ readonly: true,
326
+ interface: 'input',
327
+ schemaMetadata: { primaryKey: true, length: 36 },
328
+ });
329
+ await metadata.createField({
330
+ collection: junctionCollection,
331
+ field: leftField,
332
+ type: leftPk.type,
333
+ required: true,
334
+ interface: 'relation-m2o',
335
+ schemaMetadata: leftColumn.schemaMetadata,
336
+ });
337
+ await metadata.createField({
338
+ collection: junctionCollection,
339
+ field: rightField,
340
+ type: rightPk.type,
341
+ required: true,
342
+ interface: 'relation-m2o',
343
+ schemaMetadata: rightColumn.schemaMetadata,
344
+ });
345
+
346
+ const leftRelation = await metadata.createRelation({
347
+ manyCollection: junctionCollection,
348
+ manyField: leftField,
349
+ oneCollection: leftCollection,
350
+ oneField: leftPrimaryKey,
351
+ junctionCollection,
352
+ junctionField: rightField,
353
+ onDelete: leftOnDelete,
354
+ metadata: { constraintName: leftFk, kind: 'm2m', side: 'left' },
355
+ });
356
+ const rightRelation = await metadata.createRelation({
357
+ manyCollection: junctionCollection,
358
+ manyField: rightField,
359
+ oneCollection: rightCollection,
360
+ oneField: rightPrimaryKey,
361
+ junctionCollection,
362
+ junctionField: leftField,
363
+ onDelete: rightOnDelete,
364
+ metadata: { constraintName: rightFk, kind: 'm2m', side: 'right' },
365
+ });
366
+
367
+ const schemaVersion = await incrementSchemaVersion(connection);
368
+ return {
369
+ junctionCollection,
370
+ leftField,
371
+ rightField,
372
+ leftRelation,
373
+ rightRelation,
374
+ schemaVersion,
375
+ };
376
+ });
377
+ } catch (error) {
378
+ const cleanupErrors = [];
379
+ try {
380
+ await metadata.deleteRelation(junctionCollection, leftField);
381
+ await metadata.deleteRelation(junctionCollection, rightField);
382
+ await metadata.deleteCollection(junctionCollection);
383
+ } catch (cleanupError) {
384
+ cleanupErrors.push(cleanupError);
385
+ }
386
+ if (tableCreated) {
387
+ try {
388
+ await connection.query(`DROP TABLE IF EXISTS ${junctionSql}`);
389
+ } catch (cleanupError) {
390
+ cleanupErrors.push(cleanupError);
391
+ }
392
+ }
393
+ if (cleanupErrors.length > 0) {
394
+ error.cleanupErrors = cleanupErrors;
395
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
396
+ }
397
+ throw error;
398
+ }
399
+ });
400
+ }
401
+
402
+ async deleteM2O(manyCollection, manyField) {
403
+ assertSchemaManager(this.accountability);
404
+ assertIdentifier(manyCollection, 'many collection');
405
+ assertIdentifier(manyField, 'many field');
406
+
407
+ return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
408
+ const metadata = new SchemaMetadataRepository(connection);
409
+ const relation = await metadata.readRelation(manyCollection, manyField);
410
+ if (!relation) {
411
+ const error = new Error(`Unknown relation: ${manyCollection}.${manyField}`);
412
+ error.code = 'RELATION_NOT_FOUND';
413
+ throw error;
414
+ }
415
+
416
+ const relationMetadata = parseRelationMetadata(relation.metadata);
417
+ const fkName = relationMetadata.constraintName
418
+ ?? constraintName(relation.many_collection, relation.many_field, relation.one_collection);
419
+ const manyTableSql = quoteIdentifier(relation.many_collection, 'many collection');
420
+ const manyFieldSql = quoteIdentifier(relation.many_field, 'many field');
421
+ const oneTableSql = quoteIdentifier(relation.one_collection, 'one collection');
422
+ const oneFieldSql = quoteIdentifier(relation.one_field, 'one field');
423
+ const constraintSql = quoteIdentifier(fkName, 'constraint name');
424
+ const onDelete = assertOnDelete(relation.on_delete);
425
+
426
+ await connection.query(`ALTER TABLE ${manyTableSql} DROP FOREIGN KEY ${constraintSql}`);
427
+
428
+ try {
429
+ return await withConnectionTransaction(connection, async () => {
430
+ const deleted = await metadata.deleteRelation(manyCollection, manyField);
431
+ if (deleted !== 1) {
432
+ const error = new Error(`Relation metadata disappeared during delete: ${manyCollection}.${manyField}`);
433
+ error.code = 'SCHEMA_METADATA_DRIFT';
434
+ throw error;
435
+ }
436
+ const schemaVersion = await incrementSchemaVersion(connection);
437
+ return { deleted: true, schemaVersion };
438
+ });
439
+ } catch (error) {
440
+ try {
441
+ await connection.query(
442
+ `ALTER TABLE ${manyTableSql}
443
+ ADD CONSTRAINT ${constraintSql}
444
+ FOREIGN KEY (${manyFieldSql}) REFERENCES ${oneTableSql} (${oneFieldSql})
445
+ ON DELETE ${onDelete}`,
446
+ );
447
+ } catch (restoreError) {
448
+ error.restoreError = restoreError;
449
+ error.code ||= 'SCHEMA_PARTIAL_FAILURE';
450
+ }
451
+ throw error;
452
+ }
453
+ });
454
+ }
455
+ }
@@ -0,0 +1,160 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { BaseService } from './base-service.js';
4
+
5
+ function assertRoleManager(accountability) {
6
+ if (accountability.admin === true || accountability.system === true) return;
7
+ const error = new Error('Role management requires administrator accountability');
8
+ error.code = 'FORBIDDEN';
9
+ throw error;
10
+ }
11
+
12
+ function normalizeRoleName(name) {
13
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
14
+ const error = new Error('Role name is required');
15
+ error.code = 'INVALID_PAYLOAD';
16
+ throw error;
17
+ }
18
+ const normalized = name.trim();
19
+ if (normalized.length > 100) {
20
+ const error = new Error('Role name cannot exceed 100 characters');
21
+ error.code = 'INVALID_PAYLOAD';
22
+ throw error;
23
+ }
24
+ return normalized;
25
+ }
26
+
27
+ export class RolesService extends BaseService {
28
+ async readMany() {
29
+ assertRoleManager(this.accountability);
30
+ const [rows] = await this.database.query(
31
+ `SELECT id, name, description, admin, public, created_at, updated_at
32
+ FROM yuncms_roles
33
+ ORDER BY name ASC`,
34
+ );
35
+ return rows;
36
+ }
37
+
38
+ async readOne(id) {
39
+ assertRoleManager(this.accountability);
40
+ const [rows] = await this.database.query(
41
+ `SELECT id, name, description, admin, public, created_at, updated_at
42
+ FROM yuncms_roles
43
+ WHERE id = ?
44
+ LIMIT 1`,
45
+ [id],
46
+ );
47
+ return rows[0] ?? null;
48
+ }
49
+
50
+ async createOne(input = {}) {
51
+ assertRoleManager(this.accountability);
52
+ const name = normalizeRoleName(input.name);
53
+
54
+ const admin = input.admin === true;
55
+ const publicRole = input.public === true;
56
+ if (admin && publicRole) {
57
+ const error = new Error('A role cannot be both administrator and public');
58
+ error.code = 'INVALID_ROLE';
59
+ throw error;
60
+ }
61
+
62
+ if (publicRole) {
63
+ const [rows] = await this.database.query(
64
+ 'SELECT id FROM yuncms_roles WHERE public = 1 LIMIT 1',
65
+ );
66
+ if (rows[0]) {
67
+ const error = new Error('A public role already exists');
68
+ error.code = 'PUBLIC_ROLE_EXISTS';
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ const id = randomUUID();
74
+ await this.database.query(
75
+ `INSERT INTO yuncms_roles (id, name, description, admin, public)
76
+ VALUES (?, ?, ?, ?, ?)`,
77
+ [
78
+ id,
79
+ name,
80
+ input.description ?? null,
81
+ admin ? 1 : 0,
82
+ publicRole ? 1 : 0,
83
+ ],
84
+ );
85
+ return this.readOne(id);
86
+ }
87
+
88
+ async updateOne(id, patch = {}) {
89
+ assertRoleManager(this.accountability);
90
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
91
+ const error = new Error('Role patch must be an object');
92
+ error.code = 'INVALID_PAYLOAD';
93
+ throw error;
94
+ }
95
+ const keys = Object.keys(patch);
96
+ if (keys.length === 0 || keys.some((key) => !['name', 'description'].includes(key))) {
97
+ const error = new Error('Role update supports name and description only in V1');
98
+ error.code = 'INVALID_PAYLOAD';
99
+ throw error;
100
+ }
101
+
102
+ const existing = await this.readOne(id);
103
+ if (!existing) {
104
+ const error = new Error(`Unknown role: ${id}`);
105
+ error.code = 'ROLE_NOT_FOUND';
106
+ throw error;
107
+ }
108
+
109
+ const assignments = [];
110
+ const params = [];
111
+ if (Object.hasOwn(patch, 'name')) {
112
+ assignments.push('name = ?');
113
+ params.push(normalizeRoleName(patch.name));
114
+ }
115
+ if (Object.hasOwn(patch, 'description')) {
116
+ assignments.push('description = ?');
117
+ params.push(patch.description ?? null);
118
+ }
119
+ params.push(id);
120
+
121
+ await this.database.query(
122
+ `UPDATE yuncms_roles SET ${assignments.join(', ')} WHERE id = ?`,
123
+ params,
124
+ );
125
+ return this.readOne(id);
126
+ }
127
+
128
+ async deleteOne(id) {
129
+ assertRoleManager(this.accountability);
130
+ const role = await this.readOne(id);
131
+ if (!role) {
132
+ const error = new Error(`Unknown role: ${id}`);
133
+ error.code = 'ROLE_NOT_FOUND';
134
+ throw error;
135
+ }
136
+ if (Boolean(role.admin) || Boolean(role.public)) {
137
+ const error = new Error('Administrator and public roles cannot be deleted through the V1 role API');
138
+ error.code = 'PROTECTED_ROLE';
139
+ throw error;
140
+ }
141
+
142
+ const [users] = await this.database.query(
143
+ 'SELECT id FROM yuncms_users WHERE role = ? LIMIT 1',
144
+ [id],
145
+ );
146
+ if (users[0]) {
147
+ const error = new Error('Role cannot be deleted while users are assigned to it');
148
+ error.code = 'ROLE_IN_USE';
149
+ throw error;
150
+ }
151
+
152
+ const [result] = await this.database.query('DELETE FROM yuncms_roles WHERE id = ?', [id]);
153
+ if (result.affectedRows !== 1) {
154
+ const error = new Error(`Unknown role: ${id}`);
155
+ error.code = 'ROLE_NOT_FOUND';
156
+ throw error;
157
+ }
158
+ return true;
159
+ }
160
+ }
@@ -0,0 +1,7 @@
1
+ export function assertSchemaManager(accountability) {
2
+ if (accountability?.admin === true || accountability?.system === true) return;
3
+
4
+ const error = new Error('Schema management requires administrator accountability');
5
+ error.code = 'FORBIDDEN';
6
+ throw error;
7
+ }
@@ -0,0 +1,32 @@
1
+ export function createServiceRegistry(initialServices = {}) {
2
+ const services = new Map();
3
+
4
+ for (const [name, Service] of Object.entries(initialServices)) {
5
+ register(name, Service);
6
+ }
7
+
8
+ function register(name, Service) {
9
+ if (!name || typeof name !== 'string') throw new Error('Service name is required');
10
+ if (typeof Service !== 'function') throw new Error(`Service ${name} must be a constructor`);
11
+ if (services.has(name)) throw new Error(`Service ${name} is already registered`);
12
+
13
+ services.set(name, Service);
14
+ return Service;
15
+ }
16
+
17
+ function get(name) {
18
+ const Service = services.get(name);
19
+ if (!Service) throw new Error(`Unknown service: ${name}`);
20
+ return Service;
21
+ }
22
+
23
+ function has(name) {
24
+ return services.has(name);
25
+ }
26
+
27
+ function toObject() {
28
+ return Object.freeze(Object.fromEntries(services.entries()));
29
+ }
30
+
31
+ return Object.freeze({ register, get, has, toObject });
32
+ }