@yunsoft/yuncms-api 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yunsoft Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @yunsoft/yuncms-api
2
+
3
+ Express API runtime and bundled React Studio server for YunCMS.
4
+
5
+ The normal entry point is the `yuncms` executable provided by `@yunsoft/yuncms`. See the [project repository](https://github.com/Yunsoft-Software/yuncms) for configuration and deployment documentation.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@yunsoft/yuncms-api",
3
+ "version": "0.1.0",
4
+ "description": "Express API runtime and bundled Studio server for YunCMS.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24 <25"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "studio-dist"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Yunsoft-Software/yuncms.git",
17
+ "directory": "packages/api"
18
+ },
19
+ "homepage": "https://github.com/Yunsoft-Software/yuncms#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/Yunsoft-Software/yuncms/issues"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "exports": {
27
+ "./server": "./src/server.js"
28
+ },
29
+ "scripts": {
30
+ "dev": "node --watch src/server.js",
31
+ "start": "node src/server.js",
32
+ "test": "node --test"
33
+ },
34
+ "dependencies": {
35
+ "@yunsoft/yuncms-core": "0.1.0",
36
+ "express": "5.2.1"
37
+ }
38
+ }
package/src/app.js ADDED
@@ -0,0 +1,130 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import express from 'express';
3
+ import {
4
+ createCoreServiceRegistry,
5
+ pingDatabase,
6
+ } from '@yunsoft/yuncms-core';
7
+
8
+ import { createAuthenticationMiddleware } from './authentication.js';
9
+ import { apiErrorHandler } from './error-response.js';
10
+ import { createAuditRouter } from './routes/audit.js';
11
+ import { createAuthRouter } from './routes/auth.js';
12
+ import { createFilesRouter } from './routes/files.js';
13
+ import { createItemsRouter } from './routes/items.js';
14
+ import { createPermissionsRouter } from './routes/permissions.js';
15
+ import { createRolesRouter } from './routes/roles.js';
16
+ import { createSchemaRouter } from './routes/schema.js';
17
+ import { createUsersRouter } from './routes/users.js';
18
+ import { createStudioMiddleware } from './studio.js';
19
+
20
+ function securityHeaders(req, res, next) {
21
+ res.set('x-content-type-options', 'nosniff');
22
+ res.set('x-frame-options', 'DENY');
23
+ res.set('referrer-policy', 'no-referrer');
24
+ res.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
25
+ res.set('cross-origin-resource-policy', 'same-origin');
26
+ next();
27
+ }
28
+
29
+ function studioCors(config) {
30
+ return (req, res, next) => {
31
+ const origin = req.get('origin');
32
+ const allowedOrigin = config?.server?.studioOrigin;
33
+
34
+ if (origin && allowedOrigin && origin === allowedOrigin) {
35
+ res.set('access-control-allow-origin', origin);
36
+ res.set('vary', 'Origin');
37
+ res.set('access-control-allow-headers', 'content-type, authorization, x-request-id, x-filename, x-title, x-mimetype');
38
+ res.set('access-control-allow-methods', 'GET,POST,PATCH,DELETE,OPTIONS');
39
+ }
40
+
41
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
42
+ return next();
43
+ };
44
+ }
45
+
46
+ export function createApp({
47
+ pool,
48
+ config,
49
+ logger = console,
50
+ serviceRegistry = createCoreServiceRegistry(),
51
+ schemaCache = null,
52
+ emitter = null,
53
+ storage = null,
54
+ mailer = null,
55
+ endpointExtensions = [],
56
+ studioRoot = undefined,
57
+ }) {
58
+ if (!pool) throw new Error('Database pool is required');
59
+ if (!config) throw new Error('Config is required');
60
+ if (!Array.isArray(endpointExtensions)) throw new Error('endpointExtensions must be an array');
61
+
62
+ const services = serviceRegistry.toObject();
63
+ const app = express();
64
+ app.disable('x-powered-by');
65
+ app.use(securityHeaders);
66
+ app.use(studioCors(config));
67
+ app.use(express.json({ limit: '1mb' }));
68
+ app.use((req, res, next) => {
69
+ req.id = req.get('x-request-id') || randomUUID();
70
+ res.set('x-request-id', req.id);
71
+ next();
72
+ });
73
+
74
+ app.get('/health', (req, res) => {
75
+ res.json({ status: 'ok', request_id: req.id });
76
+ });
77
+
78
+ app.get('/ready', async (req, res) => {
79
+ try {
80
+ const ready = await pingDatabase(pool);
81
+ if (!ready) throw new Error('Database ping returned an unexpected result');
82
+ res.json({ status: 'ready', request_id: req.id });
83
+ } catch (error) {
84
+ logger.warn?.('YunCMS readiness check failed', { requestId: req.id, error });
85
+ res.status(503).json({
86
+ status: 'not_ready',
87
+ request_id: req.id,
88
+ errors: [{ code: 'DATABASE_UNAVAILABLE', message: 'Database is not ready' }],
89
+ });
90
+ }
91
+ });
92
+
93
+ app.use(createStudioMiddleware({ root: studioRoot }));
94
+
95
+ app.use(createAuthenticationMiddleware({
96
+ pool,
97
+ config,
98
+ logger,
99
+ services,
100
+ schemaCache,
101
+ emitter,
102
+ storage,
103
+ }));
104
+ app.use('/auth', createAuthRouter({ mailer, config, logger }));
105
+ app.use('/items', createItemsRouter());
106
+ app.use('/schema', createSchemaRouter());
107
+ app.use('/users', createUsersRouter());
108
+ app.use('/roles', createRolesRouter());
109
+ app.use('/permissions', createPermissionsRouter());
110
+ app.use('/files', createFilesRouter({ maxUploadBytes: config.storage?.maxUploadBytes }));
111
+ app.use('/audit', createAuditRouter());
112
+
113
+ for (const extension of endpointExtensions) {
114
+ if (!extension?.id || !extension?.router) {
115
+ throw new Error('Invalid endpoint extension runtime entry');
116
+ }
117
+ app.use(`/extensions/${encodeURIComponent(extension.id)}`, extension.router);
118
+ }
119
+
120
+ app.use((req, res) => {
121
+ res.status(404).json({
122
+ errors: [{ code: 'NOT_FOUND', message: 'Route not found', request_id: req.id }],
123
+ });
124
+ });
125
+
126
+ app.use(apiErrorHandler(logger));
127
+ return app;
128
+ }
129
+
130
+ export { securityHeaders, studioCors };
@@ -0,0 +1,91 @@
1
+ import {
2
+ createAccountability,
3
+ createPublicAccountability,
4
+ createRequestContext,
5
+ } from '@yunsoft/yuncms-core';
6
+
7
+ function bearerToken(header) {
8
+ if (!header) return null;
9
+ const match = /^Bearer\s+([^\s]+)$/i.exec(header);
10
+ if (!match) {
11
+ const error = new Error('Authorization header must use Bearer authentication');
12
+ error.code = 'INVALID_CREDENTIALS';
13
+ throw error;
14
+ }
15
+ return match[1];
16
+ }
17
+
18
+ function isPublicAuthRoute(req) {
19
+ return req.method === 'POST' && (req.path === '/auth/login' || req.path === '/auth/refresh');
20
+ }
21
+
22
+ export function createAuthenticationMiddleware({
23
+ pool,
24
+ config,
25
+ logger,
26
+ services,
27
+ schemaCache = null,
28
+ emitter = null,
29
+ storage = null,
30
+ }) {
31
+ return async (req, _res, next) => {
32
+ try {
33
+ const AuthService = services.AuthService;
34
+ const bootstrapAccountability = createPublicAccountability();
35
+ const auth = new AuthService({
36
+ accountability: bootstrapAccountability,
37
+ database: pool,
38
+ logger,
39
+ emitter,
40
+ storage,
41
+ });
42
+
43
+ const token = bearerToken(req.get('authorization'));
44
+ let accountability;
45
+
46
+ if (token) {
47
+ const identity = await auth.authenticateBearerToken(token);
48
+ accountability = createAccountability({
49
+ user: identity.user,
50
+ role: identity.role,
51
+ admin: identity.admin === true,
52
+ });
53
+ req.authToken = token;
54
+ req.authMethod = identity.authMethod;
55
+ req.sessionId = identity.session ?? null;
56
+ req.apiTokenId = identity.apiToken ?? null;
57
+ } else if (isPublicAuthRoute(req)) {
58
+ accountability = bootstrapAccountability;
59
+ req.authToken = null;
60
+ req.authMethod = 'public';
61
+ req.sessionId = null;
62
+ req.apiTokenId = null;
63
+ } else {
64
+ accountability = await auth.resolvePublicAccountability();
65
+ req.authToken = null;
66
+ req.authMethod = 'public';
67
+ req.sessionId = null;
68
+ req.apiTokenId = null;
69
+ }
70
+
71
+ const schema = schemaCache ? await schemaCache.get(pool) : null;
72
+ req.accountability = accountability;
73
+ req.context = createRequestContext({
74
+ accountability,
75
+ services,
76
+ database: pool,
77
+ schema,
78
+ logger,
79
+ env: config,
80
+ emitter,
81
+ storage,
82
+ requestId: req.id,
83
+ });
84
+ next();
85
+ } catch (error) {
86
+ next(error);
87
+ }
88
+ };
89
+ }
90
+
91
+ export { bearerToken, isPublicAuthRoute };
@@ -0,0 +1,143 @@
1
+ import { normalizeDatabaseError } from '@yunsoft/yuncms-core';
2
+
3
+ const STATUS_BY_CODE = new Map([
4
+ ['INVALID_CREDENTIALS', 401],
5
+ ['UNAUTHORIZED', 401],
6
+ ['FORBIDDEN', 403],
7
+ ['FORBIDDEN_FIELD', 403],
8
+ ['SELF_ADMIN_MUTATION_FORBIDDEN', 403],
9
+ ['PROTECTED_ROLE', 403],
10
+ ['SYSTEM_SCHEMA_READ_ONLY', 403],
11
+ ['COLLECTION_NOT_FOUND', 404],
12
+ ['FIELD_NOT_FOUND', 404],
13
+ ['RELATION_NOT_FOUND', 404],
14
+ ['USER_NOT_FOUND', 404],
15
+ ['ROLE_NOT_FOUND', 404],
16
+ ['PERMISSION_NOT_FOUND', 404],
17
+ ['FILE_NOT_FOUND', 404],
18
+ ['NOT_FOUND', 404],
19
+ ['COLLECTION_EXISTS', 409],
20
+ ['FIELD_EXISTS', 409],
21
+ ['RELATION_EXISTS', 409],
22
+ ['COLLECTION_HAS_RELATIONS', 409],
23
+ ['M2M_JUNCTION_INVALID', 409],
24
+ ['STORAGE_INVENTORY_LIMIT', 409],
25
+ ['PUBLIC_ROLE_EXISTS', 409],
26
+ ['ROLE_IN_USE', 409],
27
+ ['SCHEMA_METADATA_DRIFT', 409],
28
+ ['DUPLICATE_KEY', 409],
29
+ ['FOREIGN_KEY_MISSING', 409],
30
+ ['FOREIGN_KEY_RESTRICTED', 409],
31
+ ['RATE_LIMITED', 429],
32
+ ['PAYLOAD_TOO_LARGE', 413],
33
+ ['INVALID_QUERY', 400],
34
+ ['INVALID_PAYLOAD', 400],
35
+ ['INVALID_PASSWORD', 400],
36
+ ['INVALID_TOKEN', 400],
37
+ ['INVALID_ROLE', 400],
38
+ ['INVALID_PERMISSION', 400],
39
+ ['INVALID_SCHEMA_PAYLOAD', 400],
40
+ ['INVALID_AUDIT_EVENT', 400],
41
+ ['INVALID_ON_DELETE', 400],
42
+ ['INVALID_STORAGE_KEY', 400],
43
+ ['INVALID_FILE_CONTENT', 400],
44
+ ['INVALID_MAIL_MESSAGE', 400],
45
+ ['STORAGE_NOT_FOUND', 400],
46
+ ['STORAGE_INVENTORY_UNSUPPORTED', 400],
47
+ ['UNSUPPORTED_SCHEMA_UPDATE', 400],
48
+ ['UNSUPPORTED_FIELD_TYPE', 400],
49
+ ['UNSUPPORTED_FIELD_DEFAULT', 400],
50
+ ['UNSUPPORTED_PRIMARY_KEY', 400],
51
+ ['UNSUPPORTED_RELATION_TARGET', 400],
52
+ ['UNSUPPORTED_RELATION_EXPANSION', 400],
53
+ ['RELATION_TYPE_MISMATCH', 400],
54
+ ['DESTRUCTIVE_OPERATION_REQUIRED', 400],
55
+ ['REQUIRED_FIELD_MISSING', 400],
56
+ ['FIELD_READ_ONLY', 400],
57
+ ['FILTER_REQUIRED', 400],
58
+ ['VALIDATION_FAILED', 400],
59
+ ['VALIDATION_BULK_LIMIT', 400],
60
+ ['MAIL_NOT_CONFIGURED', 503],
61
+ ['STORAGE_INVENTORY_FAILED', 503],
62
+ ['DATABASE_MIGRATION_REQUIRED', 503],
63
+ ['DEADLOCK', 503],
64
+ ['LOCK_WAIT_TIMEOUT', 503],
65
+ ['CONNECTION_LOST', 503],
66
+ ['CONNECTION_REFUSED', 503],
67
+ ]);
68
+
69
+ const MYSQL_CODES = new Set([
70
+ 'ER_DUP_ENTRY',
71
+ 'ER_NO_REFERENCED_ROW_2',
72
+ 'ER_ROW_IS_REFERENCED_2',
73
+ 'ER_LOCK_DEADLOCK',
74
+ 'ER_LOCK_WAIT_TIMEOUT',
75
+ 'PROTOCOL_CONNECTION_LOST',
76
+ 'ECONNREFUSED',
77
+ ]);
78
+
79
+ const SAFE_DATABASE_MESSAGES = new Map([
80
+ ['DUPLICATE_KEY', 'A record with the same unique value already exists'],
81
+ ['FOREIGN_KEY_MISSING', 'A referenced record does not exist'],
82
+ ['FOREIGN_KEY_RESTRICTED', 'The record is still referenced and cannot be removed'],
83
+ ['DEADLOCK', 'The database transaction could not complete; retry the request'],
84
+ ['LOCK_WAIT_TIMEOUT', 'The database operation timed out waiting for a lock'],
85
+ ['CONNECTION_LOST', 'Database connection was lost'],
86
+ ['CONNECTION_REFUSED', 'Database connection is unavailable'],
87
+ ]);
88
+
89
+ function normalizeApiError(error) {
90
+ if (error?.type === 'entity.too.large') {
91
+ const normalized = new Error('Request body exceeds the configured upload limit');
92
+ normalized.code = 'PAYLOAD_TOO_LARGE';
93
+ normalized.cause = error;
94
+ return normalized;
95
+ }
96
+ if (!MYSQL_CODES.has(error?.code)) return error;
97
+ const databaseError = normalizeDatabaseError(error);
98
+ const normalized = new Error(
99
+ SAFE_DATABASE_MESSAGES.get(databaseError.code) ?? 'Database operation failed',
100
+ );
101
+ normalized.code = databaseError.code;
102
+ normalized.cause = databaseError;
103
+ return normalized;
104
+ }
105
+
106
+ export function statusForError(error) {
107
+ return STATUS_BY_CODE.get(error?.code) ?? 500;
108
+ }
109
+
110
+ export function errorBody(error, requestId) {
111
+ const status = statusForError(error);
112
+ const exposeMessage = status < 500;
113
+
114
+ return {
115
+ errors: [
116
+ {
117
+ code: error?.code ?? 'INTERNAL_ERROR',
118
+ message: exposeMessage ? (error?.message ?? 'Request failed') : 'Internal server error',
119
+ ...(error?.path ? { path: error.path } : {}),
120
+ request_id: requestId,
121
+ },
122
+ ],
123
+ };
124
+ }
125
+
126
+ export function apiErrorHandler(logger = console) {
127
+ return (error, req, res, _next) => {
128
+ const normalized = normalizeApiError(error);
129
+ const status = statusForError(normalized);
130
+
131
+ if (status >= 500) {
132
+ logger.error?.('YunCMS API request failed', {
133
+ requestId: req.id,
134
+ code: normalized?.code,
135
+ error,
136
+ });
137
+ }
138
+
139
+ res.status(status).json(errorBody(normalized, req.id));
140
+ };
141
+ }
142
+
143
+ export { normalizeApiError };
@@ -0,0 +1,144 @@
1
+ import { createRequire } from 'node:module';
2
+ import { access, readFile, readdir } from 'node:fs/promises';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+
6
+ import { validateExtensionManifest } from './manifest.js';
7
+
8
+ async function readJson(path) {
9
+ return JSON.parse(await readFile(path, 'utf8'));
10
+ }
11
+
12
+ async function exists(path) {
13
+ try {
14
+ await access(path, fsConstants.R_OK);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ async function findPackageRootFromEntry(entryPath, packageName) {
22
+ let current = dirname(entryPath);
23
+
24
+ while (true) {
25
+ const packagePath = join(current, 'package.json');
26
+ if (await exists(packagePath)) {
27
+ try {
28
+ const packageJson = await readJson(packagePath);
29
+ if (packageJson.name === packageName) return current;
30
+ } catch {
31
+ // Keep walking; a parent package may be the requested package root.
32
+ }
33
+ }
34
+
35
+ const parent = dirname(current);
36
+ if (parent === current) break;
37
+ current = parent;
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ async function resolveDependencyPackageRoot(rootDir, packageName) {
44
+ const requireFromProject = createRequire(join(resolve(rootDir), 'package.json'));
45
+
46
+ try {
47
+ return dirname(requireFromProject.resolve(`${packageName}/package.json`));
48
+ } catch (packageJsonError) {
49
+ try {
50
+ const entry = requireFromProject.resolve(packageName);
51
+ const root = await findPackageRootFromEntry(entry, packageName);
52
+ if (root) return root;
53
+ } catch {
54
+ // Fall through to the original package-json resolution error below.
55
+ }
56
+
57
+ if (/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i.test(packageName)) {
58
+ const installedRoot = join(resolve(rootDir), 'node_modules', ...packageName.split('/'));
59
+ const installedPackagePath = join(installedRoot, 'package.json');
60
+ if (await exists(installedPackagePath)) {
61
+ const installedPackage = await readJson(installedPackagePath);
62
+ if (installedPackage.name === packageName) return installedRoot;
63
+ }
64
+ }
65
+
66
+ const error = new Error(`Unable to resolve installed extension package: ${packageName}`);
67
+ error.code = 'EXTENSION_PACKAGE_NOT_RESOLVED';
68
+ error.cause = packageJsonError;
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ async function discoverLocalExtensions(rootDir, localDirectory) {
74
+ const directory = resolve(rootDir, localDirectory);
75
+ if (!(await exists(directory))) return [];
76
+
77
+ const entries = await readdir(directory, { withFileTypes: true });
78
+ const discovered = [];
79
+
80
+ for (const entry of entries) {
81
+ if (!entry.isDirectory()) continue;
82
+ const packageRoot = join(directory, entry.name);
83
+ const packagePath = join(packageRoot, 'package.json');
84
+ if (!(await exists(packagePath))) continue;
85
+
86
+ const packageJson = await readJson(packagePath);
87
+ const manifest = validateExtensionManifest(packageJson, packageRoot);
88
+ if (manifest) discovered.push({ ...manifest, source: 'local' });
89
+ }
90
+
91
+ return discovered;
92
+ }
93
+
94
+ async function discoverDependencyExtensions(rootDir) {
95
+ const rootPackagePath = join(resolve(rootDir), 'package.json');
96
+ if (!(await exists(rootPackagePath))) return [];
97
+
98
+ const rootPackage = await readJson(rootPackagePath);
99
+ const packageNames = new Set([
100
+ ...Object.keys(rootPackage.dependencies ?? {}),
101
+ ...Object.keys(rootPackage.optionalDependencies ?? {}),
102
+ ...Object.keys(rootPackage.devDependencies ?? {}),
103
+ ]);
104
+ const discovered = [];
105
+
106
+ for (const packageName of packageNames) {
107
+ let packageRoot;
108
+ try {
109
+ packageRoot = await resolveDependencyPackageRoot(rootDir, packageName);
110
+ } catch (error) {
111
+ if (rootPackage.optionalDependencies?.[packageName]) continue;
112
+ throw error;
113
+ }
114
+
115
+ const packageJson = await readJson(join(packageRoot, 'package.json'));
116
+ const manifest = validateExtensionManifest(packageJson, packageRoot);
117
+ if (manifest) discovered.push({ ...manifest, source: 'npm' });
118
+ }
119
+
120
+ return discovered;
121
+ }
122
+
123
+ export async function discoverExtensions({
124
+ rootDir = process.cwd(),
125
+ localDirectory = 'extensions',
126
+ includeDependencies = true,
127
+ } = {}) {
128
+ const extensions = [
129
+ ...(await discoverLocalExtensions(rootDir, localDirectory)),
130
+ ...(includeDependencies ? await discoverDependencyExtensions(rootDir) : []),
131
+ ];
132
+
133
+ const ids = new Set();
134
+ for (const extension of extensions) {
135
+ if (ids.has(extension.id)) {
136
+ const error = new Error(`Duplicate YunCMS extension id: ${extension.id}`);
137
+ error.code = 'DUPLICATE_EXTENSION_ID';
138
+ throw error;
139
+ }
140
+ ids.add(extension.id);
141
+ }
142
+
143
+ return extensions.sort((a, b) => a.id.localeCompare(b.id));
144
+ }
@@ -0,0 +1,65 @@
1
+ import { resolve, sep } from 'node:path';
2
+
3
+ const TYPES = new Set(['endpoint', 'hook']);
4
+ const ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
5
+
6
+ function extensionError(code, message) {
7
+ const error = new Error(message);
8
+ error.code = code;
9
+ return error;
10
+ }
11
+
12
+ function defaultId(packageName) {
13
+ const raw = String(packageName ?? '').split('/').at(-1)?.toLowerCase() ?? '';
14
+ return raw.replace(/^yuncms-extension-/, '').replace(/[^a-z0-9_-]/g, '-');
15
+ }
16
+
17
+ export function validateExtensionManifest(packageJson, packageRoot) {
18
+ if (!packageJson || typeof packageJson !== 'object') {
19
+ throw extensionError('INVALID_EXTENSION_MANIFEST', 'Extension package.json must be an object');
20
+ }
21
+ if (!packageJson.yuncms || typeof packageJson.yuncms !== 'object' || Array.isArray(packageJson.yuncms)) {
22
+ return null;
23
+ }
24
+
25
+ const manifest = packageJson.yuncms;
26
+ const type = manifest.type;
27
+ if (!TYPES.has(type)) {
28
+ throw extensionError(
29
+ 'INVALID_EXTENSION_MANIFEST',
30
+ `Extension ${packageJson.name ?? '<unnamed>'} has unsupported type: ${String(type)}`,
31
+ );
32
+ }
33
+
34
+ const id = String(manifest.id ?? defaultId(packageJson.name));
35
+ if (!ID_PATTERN.test(id)) {
36
+ throw extensionError(
37
+ 'INVALID_EXTENSION_MANIFEST',
38
+ `Extension id must match ${ID_PATTERN}: ${id}`,
39
+ );
40
+ }
41
+
42
+ if (typeof manifest.entry !== 'string' || manifest.entry.trim() === '') {
43
+ throw extensionError(
44
+ 'INVALID_EXTENSION_MANIFEST',
45
+ `Extension ${id} requires a yuncms.entry path`,
46
+ );
47
+ }
48
+
49
+ const root = resolve(packageRoot);
50
+ const entry = resolve(root, manifest.entry);
51
+ if (entry !== root && !entry.startsWith(`${root}${sep}`)) {
52
+ throw extensionError(
53
+ 'INVALID_EXTENSION_MANIFEST',
54
+ `Extension ${id} entry escapes its package root`,
55
+ );
56
+ }
57
+
58
+ return Object.freeze({
59
+ id,
60
+ type,
61
+ entry,
62
+ packageName: packageJson.name ?? id,
63
+ packageRoot: root,
64
+ });
65
+ }