@yunsoft/yuncms-api 0.1.3 → 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.3",
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.3",
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';
@@ -20,6 +22,21 @@ import { createUsersRouter } from './routes/users.js';
20
22
  import { createStudioMiddleware } from './studio.js';
21
23
 
22
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('; ');
23
40
 
24
41
  function securityHeaders(req, res, next) {
25
42
  res.set('x-content-type-options', 'nosniff');
@@ -27,6 +44,12 @@ function securityHeaders(req, res, next) {
27
44
  res.set('referrer-policy', 'no-referrer');
28
45
  res.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
29
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
+ }
30
53
  next();
31
54
  }
32
55
 
@@ -54,12 +77,23 @@ function requestIdentity(req, res, next) {
54
77
  next();
55
78
  }
56
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
+
57
90
  export function createApp({
58
91
  pool,
59
92
  config,
60
93
  logger = console,
61
94
  serviceRegistry = createCoreServiceRegistry(),
62
95
  schemaCache = null,
96
+ permissionCache = null,
63
97
  emitter = null,
64
98
  storage = null,
65
99
  mailer = null,
@@ -100,12 +134,19 @@ export function createApp({
100
134
 
101
135
  app.use(createStudioMiddleware({ root: studioRoot }));
102
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
+
103
143
  app.use(createAuthenticationMiddleware({
104
144
  pool,
105
145
  config,
106
146
  logger,
107
147
  services,
108
148
  schemaCache,
149
+ permissionCache,
109
150
  emitter,
110
151
  storage,
111
152
  }));
@@ -137,4 +178,10 @@ export function createApp({
137
178
  return app;
138
179
  }
139
180
 
140
- 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
+ }
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,