@yunsoft/yuncms-api 0.1.2 → 0.1.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-api",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Express API runtime and bundled Studio server for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "test": "node --test"
33
33
  },
34
34
  "dependencies": {
35
- "@yunsoft/yuncms-core": "0.1.2",
35
+ "@yunsoft/yuncms-core": "0.1.5",
36
36
  "express": "5.2.1"
37
37
  }
38
38
  }
package/src/app.js CHANGED
@@ -7,6 +7,8 @@ import {
7
7
 
8
8
  import { createAuthenticationMiddleware } from './authentication.js';
9
9
  import { apiErrorHandler } from './error-response.js';
10
+ import { createPressureLimit } from './pressure-limit.js';
11
+ import { createFixedWindowRateLimit } from './rate-limit.js';
10
12
  import { createAuditRouter } from './routes/audit.js';
11
13
  import { createAuthRouter } from './routes/auth.js';
12
14
  import { createFilesRouter } from './routes/files.js';
@@ -15,10 +17,26 @@ import { createPermissionsRouter } from './routes/permissions.js';
15
17
  import { createRolesRouter } from './routes/roles.js';
16
18
  import { createSchemaRouter } from './routes/schema.js';
17
19
  import { createStudioSettingsRouter } from './routes/studio-settings.js';
20
+ import { createSystemSchemaRouter } from './routes/system-schema.js';
18
21
  import { createUsersRouter } from './routes/users.js';
19
22
  import { createStudioMiddleware } from './studio.js';
20
23
 
21
24
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
25
+ const CONTENT_SECURITY_POLICY = [
26
+ "default-src 'self'",
27
+ "base-uri 'self'",
28
+ "object-src 'none'",
29
+ "frame-ancestors 'none'",
30
+ "script-src 'self'",
31
+ "style-src 'self' 'unsafe-inline'",
32
+ "img-src 'self' data: blob: https://yunsoft.com",
33
+ "font-src 'self' data:",
34
+ "connect-src 'self'",
35
+ "media-src 'self' blob:",
36
+ "frame-src 'self' blob:",
37
+ "worker-src 'self' blob:",
38
+ "form-action 'self'",
39
+ ].join('; ');
22
40
 
23
41
  function securityHeaders(req, res, next) {
24
42
  res.set('x-content-type-options', 'nosniff');
@@ -26,6 +44,12 @@ function securityHeaders(req, res, next) {
26
44
  res.set('referrer-policy', 'no-referrer');
27
45
  res.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
28
46
  res.set('cross-origin-resource-policy', 'same-origin');
47
+ res.set('cross-origin-opener-policy', 'same-origin');
48
+ res.set('x-dns-prefetch-control', 'off');
49
+ res.set('content-security-policy', CONTENT_SECURITY_POLICY);
50
+ if (req.secure === true) {
51
+ res.set('strict-transport-security', 'max-age=15552000; includeSubDomains');
52
+ }
29
53
  next();
30
54
  }
31
55
 
@@ -53,12 +77,23 @@ function requestIdentity(req, res, next) {
53
77
  next();
54
78
  }
55
79
 
80
+ function createApiRateLimit(config) {
81
+ const limits = config?.server?.rateLimit;
82
+ if (!limits?.enabled) return null;
83
+ return createFixedWindowRateLimit({
84
+ windowMs: limits.windowMs,
85
+ max: limits.max,
86
+ maxBuckets: limits.maxBuckets,
87
+ });
88
+ }
89
+
56
90
  export function createApp({
57
91
  pool,
58
92
  config,
59
93
  logger = console,
60
94
  serviceRegistry = createCoreServiceRegistry(),
61
95
  schemaCache = null,
96
+ permissionCache = null,
62
97
  emitter = null,
63
98
  storage = null,
64
99
  mailer = null,
@@ -99,18 +134,26 @@ export function createApp({
99
134
 
100
135
  app.use(createStudioMiddleware({ root: studioRoot }));
101
136
 
137
+ const pressureLimit = createPressureLimit(config.server?.pressure);
138
+ if (pressureLimit) app.use(pressureLimit);
139
+
140
+ const apiRateLimit = createApiRateLimit(config);
141
+ if (apiRateLimit) app.use(apiRateLimit);
142
+
102
143
  app.use(createAuthenticationMiddleware({
103
144
  pool,
104
145
  config,
105
146
  logger,
106
147
  services,
107
148
  schemaCache,
149
+ permissionCache,
108
150
  emitter,
109
151
  storage,
110
152
  }));
111
153
  app.use('/studio-settings', createStudioSettingsRouter());
112
154
  app.use('/auth', createAuthRouter({ mailer, config, logger }));
113
155
  app.use('/items', createItemsRouter());
156
+ app.use('/schema', createSystemSchemaRouter({ schemaCache }));
114
157
  app.use('/schema', createSchemaRouter({ schemaCache }));
115
158
  app.use('/users', createUsersRouter());
116
159
  app.use('/roles', createRolesRouter());
@@ -135,4 +178,10 @@ export function createApp({
135
178
  return app;
136
179
  }
137
180
 
138
- export { requestIdentity, securityHeaders, studioCors };
181
+ export {
182
+ CONTENT_SECURITY_POLICY,
183
+ createApiRateLimit,
184
+ requestIdentity,
185
+ securityHeaders,
186
+ studioCors,
187
+ };
@@ -0,0 +1,18 @@
1
+ export const INTERNAL_AUDIT_EVENTS = Object.freeze([
2
+ 'items.create',
3
+ 'items.update',
4
+ 'items.delete',
5
+ 'files.create',
6
+ 'files.update',
7
+ 'files.delete',
8
+ 'users.create',
9
+ 'users.update',
10
+ 'users.delete',
11
+ 'users.password.update',
12
+ 'roles.create',
13
+ 'roles.update',
14
+ 'roles.delete',
15
+ 'permissions.create',
16
+ 'permissions.update',
17
+ 'permissions.delete',
18
+ ]);
@@ -25,6 +25,7 @@ export function createAuthenticationMiddleware({
25
25
  logger,
26
26
  services,
27
27
  schemaCache = null,
28
+ permissionCache = null,
28
29
  emitter = null,
29
30
  storage = null,
30
31
  }) {
@@ -79,6 +80,7 @@ export function createAuthenticationMiddleware({
79
80
  env: config,
80
81
  emitter,
81
82
  storage,
83
+ permissionCache,
82
84
  requestId: req.id,
83
85
  });
84
86
  next();
@@ -0,0 +1,51 @@
1
+ function assertPositiveInteger(value, name) {
2
+ if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
3
+ }
4
+
5
+ export function createPressureLimit({
6
+ enabled = true,
7
+ maxConcurrent = 250,
8
+ maxHeapPercent = 95,
9
+ retryAfterSeconds = 1,
10
+ memoryUsage = () => process.memoryUsage(),
11
+ } = {}) {
12
+ if (!enabled) return null;
13
+ assertPositiveInteger(maxConcurrent, 'Pressure maxConcurrent');
14
+ assertPositiveInteger(maxHeapPercent, 'Pressure maxHeapPercent');
15
+ if (maxHeapPercent > 100) throw new Error('Pressure maxHeapPercent cannot exceed 100');
16
+ assertPositiveInteger(retryAfterSeconds, 'Pressure retryAfterSeconds');
17
+ if (typeof memoryUsage !== 'function') throw new Error('Pressure memoryUsage must be a function');
18
+
19
+ let inFlight = 0;
20
+
21
+ return (req, res, next) => {
22
+ const memory = memoryUsage();
23
+ const heapPercent = memory.heapTotal > 0
24
+ ? (memory.heapUsed / memory.heapTotal) * 100
25
+ : 0;
26
+ const overloaded = inFlight >= maxConcurrent || heapPercent >= maxHeapPercent;
27
+
28
+ if (overloaded) {
29
+ res.set('retry-after', String(retryAfterSeconds));
30
+ res.status(503).json({
31
+ errors: [{
32
+ code: 'SERVER_PRESSURE',
33
+ message: 'Server is temporarily under pressure',
34
+ request_id: req.id ?? null,
35
+ }],
36
+ });
37
+ return;
38
+ }
39
+
40
+ inFlight += 1;
41
+ let released = false;
42
+ const release = () => {
43
+ if (released) return;
44
+ released = true;
45
+ inFlight = Math.max(0, inFlight - 1);
46
+ };
47
+ res.once('finish', release);
48
+ res.once('close', release);
49
+ next();
50
+ };
51
+ }
@@ -7,9 +7,25 @@ function settingsService(req) {
7
7
  return new Service(serviceOptionsFromRequest(req));
8
8
  }
9
9
 
10
+ function sendImageAsset(res, asset) {
11
+ const { file, contents } = asset;
12
+ res.set('cache-control', 'no-cache, must-revalidate');
13
+ res.set('content-security-policy', 'sandbox');
14
+ res.set('content-disposition', 'inline');
15
+ return res.type(file.mimetype || 'application/octet-stream').send(contents);
16
+ }
17
+
10
18
  export function createStudioSettingsRouter() {
11
19
  const router = express.Router();
12
20
 
21
+ router.get('/logo', async (req, res) => {
22
+ sendImageAsset(res, await settingsService(req).readLogoContent());
23
+ });
24
+
25
+ router.get('/favicon', async (req, res) => {
26
+ sendImageAsset(res, await settingsService(req).readFaviconContent());
27
+ });
28
+
13
29
  router.get('/', async (req, res) => {
14
30
  res.json({ data: await settingsService(req).readPublic() });
15
31
  });
@@ -0,0 +1,41 @@
1
+ import express from 'express';
2
+
3
+ import { serviceOptionsFromRequest } from '../service-options.js';
4
+
5
+ function service(req, name) {
6
+ const Service = req.context.services[name];
7
+ if (!Service) throw new Error(`Service is not registered: ${name}`);
8
+ return new Service(serviceOptionsFromRequest(req));
9
+ }
10
+
11
+ export function createSystemSchemaRouter({ schemaCache = null } = {}) {
12
+ const router = express.Router();
13
+
14
+ router.post('/system-collections/:collection/fields', async (req, res) => {
15
+ const data = await service(req, 'SystemCollectionFieldsService').createOne(
16
+ req.params.collection,
17
+ req.body ?? {},
18
+ );
19
+ schemaCache?.clear();
20
+ try {
21
+ await service(req, 'AuditService').record({
22
+ action: 'schema.system-field.create',
23
+ collection: req.params.collection,
24
+ itemKey: data.field,
25
+ requestId: req.id,
26
+ payload: { after: data },
27
+ });
28
+ } catch (error) {
29
+ req.context.logger?.error?.('YunCMS system field audit write failed after committed mutation', {
30
+ requestId: req.id,
31
+ collection: req.params.collection,
32
+ field: data.field,
33
+ code: error?.code,
34
+ message: error?.message,
35
+ });
36
+ }
37
+ res.status(201).json({ data });
38
+ });
39
+
40
+ return router;
41
+ }
package/src/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  assertDatabaseCompatible,
3
+ assertMaintenanceStartupAllowed,
3
4
  closeDatabasePool,
4
5
  createCoreServiceRegistry,
5
6
  createDatabasePool,
@@ -10,14 +11,20 @@ import {
10
11
  loadConfig,
11
12
  loadEnvFileIfPresent,
12
13
  LocalStorageDriver,
14
+ MemoryCacheStore,
13
15
  S3StorageDriver,
14
16
  SchemaCache,
15
17
  SmtpMailer,
16
18
  } from '@yunsoft/yuncms-core';
17
19
  import { createApp } from './app.js';
20
+ import { INTERNAL_AUDIT_EVENTS } from './audit-events.js';
18
21
  import { loadExtensionRuntime } from './extensions/runtime.js';
19
22
 
20
23
  loadEnvFileIfPresent();
24
+ await assertMaintenanceStartupAllowed({
25
+ cwd: process.cwd(),
26
+ env: process.env,
27
+ });
21
28
  const config = loadConfig();
22
29
  const logger = createJsonLogger({ level: config.logging.level });
23
30
  const pool = createDatabasePool(config.database);
@@ -35,6 +42,12 @@ if (config.storage.s3.bucket) {
35
42
  });
36
43
  }
37
44
  const storage = createStorageRegistry(storageDrivers);
45
+ const permissionCache = config.cache.enabled
46
+ ? new MemoryCacheStore({
47
+ ttlMs: config.cache.ttlMs,
48
+ maxEntries: config.cache.maxEntries,
49
+ })
50
+ : null;
38
51
 
39
52
  const hasAnyMailConfig = Boolean(
40
53
  config.mail.host || config.mail.from || config.mail.user || config.mail.password,
@@ -59,16 +72,8 @@ let shuttingDown = false;
59
72
  function registerInternalAudit({ emitter, services }) {
60
73
  const AuditService = services.AuditService;
61
74
  const systemAccountability = createSystemAccountability();
62
- const events = [
63
- 'items.create',
64
- 'items.update',
65
- 'items.delete',
66
- 'files.create',
67
- 'files.update',
68
- 'files.delete',
69
- ];
70
75
 
71
- for (const event of events) {
76
+ for (const event of INTERNAL_AUDIT_EVENTS) {
72
77
  emitter.registerAction(event, async (payload, context) => {
73
78
  try {
74
79
  const audit = new AuditService({
@@ -122,6 +127,7 @@ async function start() {
122
127
  logger,
123
128
  serviceRegistry,
124
129
  schemaCache,
130
+ permissionCache,
125
131
  emitter,
126
132
  storage,
127
133
  mailer,