@yunsoft/yuncms-core 0.1.0 → 0.1.1
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.
- package/package.json +1 -1
- package/src/bootstrap.js +12 -0
- package/src/config.js +1 -0
- package/src/index.js +7 -1
- package/src/migrations/0005-default-public-role.js +16 -0
- package/src/migrations/0006-studio-settings.js +20 -0
- package/src/services/collections-service.js +38 -6
- package/src/services/core-services.js +2 -0
- package/src/services/studio-settings-service.js +140 -0
- package/src/setup.js +49 -0
package/package.json
CHANGED
package/src/bootstrap.js
CHANGED
|
@@ -4,13 +4,18 @@ import { systemSchemaMigration } from './migrations/0001-system-schema.js';
|
|
|
4
4
|
import { sessionAccessTokensMigration } from './migrations/0002-session-access-tokens.js';
|
|
5
5
|
import { publicRoleConstraintsMigration } from './migrations/0003-public-role-constraints.js';
|
|
6
6
|
import { authActionTokensMigration } from './migrations/0004-auth-action-tokens.js';
|
|
7
|
+
import { defaultPublicRoleMigration } from './migrations/0005-default-public-role.js';
|
|
8
|
+
import { studioSettingsMigration } from './migrations/0006-studio-settings.js';
|
|
7
9
|
import { readSchemaVersion } from './schema-version.js';
|
|
10
|
+
import { ensurePublicRole } from './setup.js';
|
|
8
11
|
|
|
9
12
|
export const CORE_MIGRATIONS = Object.freeze([
|
|
10
13
|
systemSchemaMigration,
|
|
11
14
|
sessionAccessTokensMigration,
|
|
12
15
|
publicRoleConstraintsMigration,
|
|
13
16
|
authActionTokensMigration,
|
|
17
|
+
defaultPublicRoleMigration,
|
|
18
|
+
studioSettingsMigration,
|
|
14
19
|
]);
|
|
15
20
|
|
|
16
21
|
export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
|
|
@@ -23,10 +28,17 @@ export async function bootstrapDatabase(pool, { lockTimeoutSeconds = 10 } = {})
|
|
|
23
28
|
'yuncms:bootstrap',
|
|
24
29
|
async (connection) => {
|
|
25
30
|
const migrationResult = await applyMigrations(connection, CORE_MIGRATIONS);
|
|
31
|
+
const publicRole = await ensurePublicRole(connection);
|
|
26
32
|
const schemaVersion = await readSchemaVersion(connection);
|
|
33
|
+
const publicRoleMigrated = migrationResult.newlyApplied.includes(defaultPublicRoleMigration.id);
|
|
27
34
|
|
|
28
35
|
return {
|
|
29
36
|
...migrationResult,
|
|
37
|
+
publicRole: {
|
|
38
|
+
id: publicRole.id,
|
|
39
|
+
name: publicRole.name,
|
|
40
|
+
created: publicRole.created || publicRoleMigrated,
|
|
41
|
+
},
|
|
30
42
|
schemaVersion,
|
|
31
43
|
};
|
|
32
44
|
},
|
package/src/config.js
CHANGED
|
@@ -41,6 +41,7 @@ export function loadConfig(env = process.env) {
|
|
|
41
41
|
host: readString(env.HOST, '127.0.0.1'),
|
|
42
42
|
port: serverPort,
|
|
43
43
|
studioOrigin,
|
|
44
|
+
trustProxyHops: readInteger(env.TRUST_PROXY_HOPS, 0, 'TRUST_PROXY_HOPS', { min: 0, max: 10 }),
|
|
44
45
|
},
|
|
45
46
|
logging: {
|
|
46
47
|
level: readString(env.LOG_LEVEL, 'info'),
|
package/src/index.js
CHANGED
|
@@ -11,7 +11,12 @@ export {
|
|
|
11
11
|
requireAccountability,
|
|
12
12
|
} from './accountability.js';
|
|
13
13
|
export { createRequestContext } from './context.js';
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
createInitialAdmin,
|
|
16
|
+
findExistingAdmin,
|
|
17
|
+
findPublicRole,
|
|
18
|
+
ensurePublicRole,
|
|
19
|
+
} from './setup.js';
|
|
15
20
|
export { HookEmitter } from './hooks.js';
|
|
16
21
|
export { createJsonLogger, LEVELS as LOG_LEVELS } from './logger.js';
|
|
17
22
|
export { deleteM2MJunction } from './m2m-lifecycle.js';
|
|
@@ -41,6 +46,7 @@ export { RolesService } from './services/roles-service.js';
|
|
|
41
46
|
export { PermissionsService } from './services/permissions-service.js';
|
|
42
47
|
export { FilesService } from './services/files-service.js';
|
|
43
48
|
export { FileReconciliationService } from './services/file-reconciliation-service.js';
|
|
49
|
+
export { StudioSettingsService, STUDIO_SETTING_DEFAULTS } from './services/studio-settings-service.js';
|
|
44
50
|
export { SchemaMetadataRepository } from './schema-metadata-repository.js';
|
|
45
51
|
export { loadSchemaSnapshot, SchemaCache } from './schema.js';
|
|
46
52
|
export { assertFieldType, compileFieldColumn } from './field-types.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const defaultPublicRoleMigration = {
|
|
2
|
+
id: '0005-default-public-role',
|
|
3
|
+
statements: [
|
|
4
|
+
`INSERT INTO yuncms_roles (id, name, description, admin, public)
|
|
5
|
+
SELECT UUID(),
|
|
6
|
+
CASE
|
|
7
|
+
WHEN EXISTS (SELECT 1 FROM yuncms_roles AS named WHERE named.name = 'Public')
|
|
8
|
+
THEN CONCAT('Public ', LEFT(REPLACE(UUID(), '-', ''), 8))
|
|
9
|
+
ELSE 'Public'
|
|
10
|
+
END,
|
|
11
|
+
'Unauthenticated public API access. No collection access is granted by default.',
|
|
12
|
+
0,
|
|
13
|
+
1
|
|
14
|
+
WHERE NOT EXISTS (SELECT 1 FROM yuncms_roles AS existing WHERE existing.public = 1)`,
|
|
15
|
+
],
|
|
16
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const studioSettingsMigration = {
|
|
2
|
+
id: '0006-studio-settings',
|
|
3
|
+
statements: [
|
|
4
|
+
`CREATE TABLE IF NOT EXISTS yuncms_studio_settings (
|
|
5
|
+
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
|
6
|
+
brand_name VARCHAR(100) NOT NULL DEFAULT 'YunCMS',
|
|
7
|
+
logo_url VARCHAR(512) NOT NULL DEFAULT 'https://yunsoft.com/light-logo.png',
|
|
8
|
+
accent_color CHAR(7) NOT NULL DEFAULT '#2563eb',
|
|
9
|
+
theme VARCHAR(16) NOT NULL DEFAULT 'system',
|
|
10
|
+
default_locale VARCHAR(5) NOT NULL DEFAULT 'en',
|
|
11
|
+
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
12
|
+
CONSTRAINT chk_yuncms_studio_settings_singleton CHECK (id = 1),
|
|
13
|
+
CONSTRAINT chk_yuncms_studio_settings_theme CHECK (theme IN ('system', 'light', 'dark')),
|
|
14
|
+
CONSTRAINT chk_yuncms_studio_settings_locale CHECK (default_locale IN ('en', 'tr'))
|
|
15
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
16
|
+
`INSERT IGNORE INTO yuncms_studio_settings
|
|
17
|
+
(id, brand_name, logo_url, accent_color, theme, default_locale)
|
|
18
|
+
VALUES (1, 'YunCMS', 'https://yunsoft.com/light-logo.png', '#2563eb', 'system', 'en')`,
|
|
19
|
+
],
|
|
20
|
+
};
|
|
@@ -21,11 +21,15 @@ function assertUserCollectionName(collection) {
|
|
|
21
21
|
return collection;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function invalidSchemaPayload(message) {
|
|
25
|
+
const error = new Error(message);
|
|
26
|
+
error.code = 'INVALID_SCHEMA_PAYLOAD';
|
|
27
|
+
return error;
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
function assertCollectionMetadataPatch(patch) {
|
|
25
31
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
26
|
-
|
|
27
|
-
error.code = 'INVALID_SCHEMA_PAYLOAD';
|
|
28
|
-
throw error;
|
|
32
|
+
throw invalidSchemaPayload('Collection metadata patch must be an object');
|
|
29
33
|
}
|
|
30
34
|
for (const key of Object.keys(patch)) {
|
|
31
35
|
if (!COLLECTION_METADATA_KEYS.has(key)) {
|
|
@@ -35,9 +39,36 @@ function assertCollectionMetadataPatch(patch) {
|
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
41
|
if (Object.keys(patch).length === 0) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
throw invalidSchemaPayload('Collection metadata patch cannot be empty');
|
|
43
|
+
}
|
|
44
|
+
for (const key of ['singleton', 'hidden']) {
|
|
45
|
+
if (Object.hasOwn(patch, key) && typeof patch[key] !== 'boolean') {
|
|
46
|
+
throw invalidSchemaPayload(`Collection ${key} must be a boolean`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (Object.hasOwn(patch, 'note') && patch.note != null && typeof patch.note !== 'string') {
|
|
50
|
+
throw invalidSchemaPayload('Collection note must be a string or null');
|
|
51
|
+
}
|
|
52
|
+
if (Object.hasOwn(patch, 'metadata') && patch.metadata != null && (
|
|
53
|
+
typeof patch.metadata !== 'object' || Array.isArray(patch.metadata)
|
|
54
|
+
)) {
|
|
55
|
+
throw invalidSchemaPayload('Collection metadata must be an object or null');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertCollectionCreateMetadata(input) {
|
|
60
|
+
for (const key of ['singleton', 'hidden']) {
|
|
61
|
+
if (Object.hasOwn(input, key) && typeof input[key] !== 'boolean') {
|
|
62
|
+
throw invalidSchemaPayload(`Collection ${key} must be a boolean`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (Object.hasOwn(input, 'note') && input.note != null && typeof input.note !== 'string') {
|
|
66
|
+
throw invalidSchemaPayload('Collection note must be a string or null');
|
|
67
|
+
}
|
|
68
|
+
if (Object.hasOwn(input, 'metadata') && input.metadata != null && (
|
|
69
|
+
typeof input.metadata !== 'object' || Array.isArray(input.metadata)
|
|
70
|
+
)) {
|
|
71
|
+
throw invalidSchemaPayload('Collection metadata must be an object or null');
|
|
41
72
|
}
|
|
42
73
|
}
|
|
43
74
|
|
|
@@ -59,6 +90,7 @@ export class CollectionsService extends BaseService {
|
|
|
59
90
|
|
|
60
91
|
async createOne(input = {}) {
|
|
61
92
|
assertSchemaManager(this.accountability);
|
|
93
|
+
assertCollectionCreateMetadata(input);
|
|
62
94
|
const collection = assertUserCollectionName(input.collection);
|
|
63
95
|
const primaryKey = input.primaryKey ?? 'id';
|
|
64
96
|
|
|
@@ -10,6 +10,7 @@ import { ItemsService } from './items-service.js';
|
|
|
10
10
|
import { PermissionsService } from './permissions-service.js';
|
|
11
11
|
import { RelationsService } from './relations-service.js';
|
|
12
12
|
import { RolesService } from './roles-service.js';
|
|
13
|
+
import { StudioSettingsService } from './studio-settings-service.js';
|
|
13
14
|
import { UsersService } from './users-service.js';
|
|
14
15
|
import { createServiceRegistry } from './service-registry.js';
|
|
15
16
|
|
|
@@ -28,5 +29,6 @@ export function createCoreServiceRegistry() {
|
|
|
28
29
|
PermissionsService,
|
|
29
30
|
FilesService,
|
|
30
31
|
FileReconciliationService,
|
|
32
|
+
StudioSettingsService,
|
|
31
33
|
});
|
|
32
34
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { BaseService } from './base-service.js';
|
|
2
|
+
|
|
3
|
+
const THEMES = new Set(['system', 'light', 'dark']);
|
|
4
|
+
const LOCALES = new Set(['en', 'tr']);
|
|
5
|
+
const ACCENT_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
6
|
+
|
|
7
|
+
function invalid(message) {
|
|
8
|
+
const error = new Error(message);
|
|
9
|
+
error.code = 'INVALID_PAYLOAD';
|
|
10
|
+
return error;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertManager(accountability) {
|
|
14
|
+
if (accountability.admin === true || accountability.system === true) return;
|
|
15
|
+
const error = new Error('Studio settings require administrator accountability');
|
|
16
|
+
error.code = 'FORBIDDEN';
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeBrandName(value) {
|
|
21
|
+
if (typeof value !== 'string' || !value.trim()) throw invalid('Brand name is required');
|
|
22
|
+
const normalized = value.trim();
|
|
23
|
+
if (normalized.length > 100) throw invalid('Brand name cannot exceed 100 characters');
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
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');
|
|
36
|
+
}
|
|
37
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw invalid('Logo URL must use HTTP or HTTPS');
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeAccent(value) {
|
|
42
|
+
const normalized = String(value ?? '').trim();
|
|
43
|
+
if (!ACCENT_PATTERN.test(normalized)) throw invalid('Accent color must be a six-digit hex color');
|
|
44
|
+
return normalized.toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeTheme(value) {
|
|
48
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
49
|
+
if (!THEMES.has(normalized)) throw invalid('Theme must be system, light or dark');
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeLocale(value) {
|
|
54
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
55
|
+
if (!LOCALES.has(normalized)) throw invalid('Default locale must be en or tr');
|
|
56
|
+
return normalized;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function publicSettings(row) {
|
|
60
|
+
return {
|
|
61
|
+
brand_name: row.brand_name,
|
|
62
|
+
logo_url: row.logo_url,
|
|
63
|
+
accent_color: row.accent_color,
|
|
64
|
+
theme: row.theme,
|
|
65
|
+
default_locale: row.default_locale,
|
|
66
|
+
updated_at: row.updated_at ?? null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class StudioSettingsService extends BaseService {
|
|
71
|
+
async readPublic() {
|
|
72
|
+
const [rows] = await this.database.query(
|
|
73
|
+
`SELECT brand_name, logo_url, accent_color, theme, default_locale, updated_at
|
|
74
|
+
FROM yuncms_studio_settings
|
|
75
|
+
WHERE id = 1
|
|
76
|
+
LIMIT 1`,
|
|
77
|
+
);
|
|
78
|
+
const row = rows[0];
|
|
79
|
+
if (!row) {
|
|
80
|
+
const error = new Error('Studio settings are missing; run YunCMS bootstrap');
|
|
81
|
+
error.code = 'DATABASE_MIGRATION_REQUIRED';
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
return publicSettings(row);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async readOne() {
|
|
88
|
+
assertManager(this.accountability);
|
|
89
|
+
return this.readPublic();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async updateOne(patch = {}) {
|
|
93
|
+
assertManager(this.accountability);
|
|
94
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid('Studio settings patch must be an object');
|
|
95
|
+
|
|
96
|
+
const keys = Object.keys(patch);
|
|
97
|
+
const allowed = new Set(['brand_name', 'logo_url', 'accent_color', 'theme', 'default_locale']);
|
|
98
|
+
if (keys.length === 0 || keys.some((key) => !allowed.has(key))) {
|
|
99
|
+
throw invalid('Studio settings patch contains unsupported properties');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const assignments = [];
|
|
103
|
+
const params = [];
|
|
104
|
+
if (Object.hasOwn(patch, 'brand_name')) {
|
|
105
|
+
assignments.push('brand_name = ?');
|
|
106
|
+
params.push(normalizeBrandName(patch.brand_name));
|
|
107
|
+
}
|
|
108
|
+
if (Object.hasOwn(patch, 'logo_url')) {
|
|
109
|
+
assignments.push('logo_url = ?');
|
|
110
|
+
params.push(normalizeLogoUrl(patch.logo_url));
|
|
111
|
+
}
|
|
112
|
+
if (Object.hasOwn(patch, 'accent_color')) {
|
|
113
|
+
assignments.push('accent_color = ?');
|
|
114
|
+
params.push(normalizeAccent(patch.accent_color));
|
|
115
|
+
}
|
|
116
|
+
if (Object.hasOwn(patch, 'theme')) {
|
|
117
|
+
assignments.push('theme = ?');
|
|
118
|
+
params.push(normalizeTheme(patch.theme));
|
|
119
|
+
}
|
|
120
|
+
if (Object.hasOwn(patch, 'default_locale')) {
|
|
121
|
+
assignments.push('default_locale = ?');
|
|
122
|
+
params.push(normalizeLocale(patch.default_locale));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
params.push(1);
|
|
126
|
+
await this.database.query(
|
|
127
|
+
`UPDATE yuncms_studio_settings SET ${assignments.join(', ')} WHERE id = ?`,
|
|
128
|
+
params,
|
|
129
|
+
);
|
|
130
|
+
return this.readPublic();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const STUDIO_SETTING_DEFAULTS = Object.freeze({
|
|
135
|
+
brand_name: 'YunCMS',
|
|
136
|
+
logo_url: 'https://yunsoft.com/light-logo.png',
|
|
137
|
+
accent_color: '#2563eb',
|
|
138
|
+
theme: 'system',
|
|
139
|
+
default_locale: 'en',
|
|
140
|
+
});
|
package/src/setup.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
import { createSystemAccountability } from './accountability.js';
|
|
2
4
|
import { withTransaction } from './transaction.js';
|
|
3
5
|
import { RolesService } from './services/roles-service.js';
|
|
4
6
|
import { UsersService } from './services/users-service.js';
|
|
5
7
|
|
|
8
|
+
const PUBLIC_ROLE_NAMES = Object.freeze(['Public', 'Public API', 'Anonymous']);
|
|
9
|
+
|
|
6
10
|
export async function findExistingAdmin(database) {
|
|
7
11
|
if (!database) throw new Error('Database handle is required');
|
|
8
12
|
const [rows] = await database.query(
|
|
@@ -16,6 +20,51 @@ export async function findExistingAdmin(database) {
|
|
|
16
20
|
return rows[0] ?? null;
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
export async function findPublicRole(database) {
|
|
24
|
+
if (!database) throw new Error('Database handle is required');
|
|
25
|
+
const [rows] = await database.query(
|
|
26
|
+
`SELECT id, name, description, admin, public
|
|
27
|
+
FROM yuncms_roles
|
|
28
|
+
WHERE public = 1
|
|
29
|
+
ORDER BY created_at ASC
|
|
30
|
+
LIMIT 2`,
|
|
31
|
+
);
|
|
32
|
+
if (rows.length > 1) {
|
|
33
|
+
const error = new Error('Multiple public roles are configured');
|
|
34
|
+
error.code = 'PUBLIC_ROLE_AMBIGUOUS';
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return rows[0] ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function availablePublicRoleName(database) {
|
|
41
|
+
for (const candidate of PUBLIC_ROLE_NAMES) {
|
|
42
|
+
const [rows] = await database.query(
|
|
43
|
+
'SELECT id FROM yuncms_roles WHERE name = ? LIMIT 1',
|
|
44
|
+
[candidate],
|
|
45
|
+
);
|
|
46
|
+
if (!rows[0]) return candidate;
|
|
47
|
+
}
|
|
48
|
+
return `Public ${randomUUID().slice(0, 8)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function ensurePublicRole(database) {
|
|
52
|
+
if (!database) throw new Error('Database handle is required');
|
|
53
|
+
const existing = await findPublicRole(database);
|
|
54
|
+
if (existing) return { ...existing, created: false };
|
|
55
|
+
|
|
56
|
+
const roles = new RolesService({
|
|
57
|
+
accountability: createSystemAccountability(),
|
|
58
|
+
database,
|
|
59
|
+
});
|
|
60
|
+
const role = await roles.createOne({
|
|
61
|
+
name: await availablePublicRoleName(database),
|
|
62
|
+
description: 'Unauthenticated public API access. No collection access is granted by default.',
|
|
63
|
+
public: true,
|
|
64
|
+
});
|
|
65
|
+
return { ...role, created: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
19
68
|
export async function createInitialAdmin(pool, { email, password } = {}) {
|
|
20
69
|
if (!pool) throw new Error('Database pool is required');
|
|
21
70
|
const accountability = createSystemAccountability();
|