@yunsoft/yuncms-api 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-api",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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.0",
35
+ "@yunsoft/yuncms-core": "0.1.1",
36
36
  "express": "5.2.1"
37
37
  }
38
38
  }
package/src/app.js CHANGED
@@ -14,9 +14,12 @@ import { createItemsRouter } from './routes/items.js';
14
14
  import { createPermissionsRouter } from './routes/permissions.js';
15
15
  import { createRolesRouter } from './routes/roles.js';
16
16
  import { createSchemaRouter } from './routes/schema.js';
17
+ import { createStudioSettingsRouter } from './routes/studio-settings.js';
17
18
  import { createUsersRouter } from './routes/users.js';
18
19
  import { createStudioMiddleware } from './studio.js';
19
20
 
21
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
22
+
20
23
  function securityHeaders(req, res, next) {
21
24
  res.set('x-content-type-options', 'nosniff');
22
25
  res.set('x-frame-options', 'DENY');
@@ -43,6 +46,13 @@ function studioCors(config) {
43
46
  };
44
47
  }
45
48
 
49
+ function requestIdentity(req, res, next) {
50
+ const supplied = req.get('x-request-id');
51
+ req.id = supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : randomUUID();
52
+ res.set('x-request-id', req.id);
53
+ next();
54
+ }
55
+
46
56
  export function createApp({
47
57
  pool,
48
58
  config,
@@ -62,14 +72,11 @@ export function createApp({
62
72
  const services = serviceRegistry.toObject();
63
73
  const app = express();
64
74
  app.disable('x-powered-by');
75
+ if (config.server?.trustProxyHops > 0) app.set('trust proxy', config.server.trustProxyHops);
65
76
  app.use(securityHeaders);
66
77
  app.use(studioCors(config));
78
+ app.use(requestIdentity);
67
79
  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
80
 
74
81
  app.get('/health', (req, res) => {
75
82
  res.json({ status: 'ok', request_id: req.id });
@@ -101,9 +108,10 @@ export function createApp({
101
108
  emitter,
102
109
  storage,
103
110
  }));
111
+ app.use('/studio-settings', createStudioSettingsRouter());
104
112
  app.use('/auth', createAuthRouter({ mailer, config, logger }));
105
113
  app.use('/items', createItemsRouter());
106
- app.use('/schema', createSchemaRouter());
114
+ app.use('/schema', createSchemaRouter({ schemaCache }));
107
115
  app.use('/users', createUsersRouter());
108
116
  app.use('/roles', createRolesRouter());
109
117
  app.use('/permissions', createPermissionsRouter());
@@ -127,4 +135,4 @@ export function createApp({
127
135
  return app;
128
136
  }
129
137
 
130
- export { securityHeaders, studioCors };
138
+ export { requestIdentity, securityHeaders, studioCors };
@@ -87,6 +87,12 @@ const SAFE_DATABASE_MESSAGES = new Map([
87
87
  ]);
88
88
 
89
89
  function normalizeApiError(error) {
90
+ if (error?.type === 'entity.parse.failed') {
91
+ const normalized = new Error('Request body contains invalid JSON');
92
+ normalized.code = 'INVALID_PAYLOAD';
93
+ normalized.cause = error;
94
+ return normalized;
95
+ }
90
96
  if (error?.type === 'entity.too.large') {
91
97
  const normalized = new Error('Request body exceeds the configured upload limit');
92
98
  normalized.code = 'PAYLOAD_TOO_LARGE';
package/src/rate-limit.js CHANGED
@@ -14,24 +14,29 @@ export function createFixedWindowRateLimit({
14
14
  } = {}) {
15
15
  if (!Number.isInteger(windowMs) || windowMs < 1000) throw new Error('Rate-limit windowMs must be at least 1000');
16
16
  if (!Number.isInteger(max) || max < 1) throw new Error('Rate-limit max must be a positive integer');
17
+ if (!Number.isInteger(maxBuckets) || maxBuckets < 1) throw new Error('Rate-limit maxBuckets must be a positive integer');
17
18
 
18
19
  const buckets = new Map();
19
20
 
20
- function prune(timestamp) {
21
- if (buckets.size < maxBuckets) return;
21
+ function ensureCapacity(timestamp) {
22
22
  for (const [bucketKey, bucket] of buckets) {
23
23
  if (bucket.resetAt <= timestamp) buckets.delete(bucketKey);
24
- if (buckets.size < maxBuckets) break;
24
+ }
25
+
26
+ while (buckets.size >= maxBuckets) {
27
+ const oldestKey = buckets.keys().next().value;
28
+ if (oldestKey === undefined) break;
29
+ buckets.delete(oldestKey);
25
30
  }
26
31
  }
27
32
 
28
33
  return (req, res, next) => {
29
34
  const timestamp = now();
30
- prune(timestamp);
31
35
  const bucketKey = String(key(req));
32
36
  let bucket = buckets.get(bucketKey);
33
37
 
34
38
  if (!bucket || bucket.resetAt <= timestamp) {
39
+ if (!bucket) ensureCapacity(timestamp);
35
40
  bucket = { count: 0, resetAt: timestamp + windowMs };
36
41
  buckets.set(bucketKey, bucket);
37
42
  }
@@ -34,15 +34,20 @@ async function auditSchema(req, { action, collection = null, itemKey = null, pay
34
34
  }
35
35
  }
36
36
 
37
- export function createSchemaRouter() {
37
+ export function createSchemaRouter({ schemaCache = null } = {}) {
38
38
  const router = express.Router();
39
39
 
40
+ function clearSchemaCache() {
41
+ schemaCache?.clear();
42
+ }
43
+
40
44
  router.get('/collections', async (req, res) => {
41
45
  res.json({ data: await service(req, 'CollectionsService').readMany() });
42
46
  });
43
47
 
44
48
  router.post('/collections', async (req, res) => {
45
49
  const data = await service(req, 'CollectionsService').createOne(req.body ?? {});
50
+ clearSchemaCache();
46
51
  await auditSchema(req, {
47
52
  action: 'schema.collection.create',
48
53
  collection: data.collection,
@@ -66,6 +71,7 @@ export function createSchemaRouter() {
66
71
  const collections = service(req, 'CollectionsService');
67
72
  const before = await collections.readOne(req.params.collection);
68
73
  const data = await collections.updateOne(req.params.collection, req.body ?? {});
74
+ clearSchemaCache();
69
75
  await auditSchema(req, {
70
76
  action: 'schema.collection.update',
71
77
  collection: req.params.collection,
@@ -81,6 +87,7 @@ export function createSchemaRouter() {
81
87
  await collections.deleteOne(req.params.collection, {
82
88
  destructive: destructiveRequested(req),
83
89
  });
90
+ clearSchemaCache();
84
91
  await auditSchema(req, {
85
92
  action: 'schema.collection.delete',
86
93
  collection: req.params.collection,
@@ -99,6 +106,7 @@ export function createSchemaRouter() {
99
106
  req.params.collection,
100
107
  req.body ?? {},
101
108
  );
109
+ clearSchemaCache();
102
110
  await auditSchema(req, {
103
111
  action: 'schema.field.create',
104
112
  collection: req.params.collection,
@@ -129,6 +137,7 @@ export function createSchemaRouter() {
129
137
  req.params.field,
130
138
  req.body ?? {},
131
139
  );
140
+ clearSchemaCache();
132
141
  await auditSchema(req, {
133
142
  action: 'schema.field.update',
134
143
  collection: req.params.collection,
@@ -146,6 +155,7 @@ export function createSchemaRouter() {
146
155
  req.params.field,
147
156
  req.body ?? {},
148
157
  );
158
+ clearSchemaCache();
149
159
  await auditSchema(req, {
150
160
  action: 'schema.field.alter',
151
161
  collection: req.params.collection,
@@ -163,6 +173,7 @@ export function createSchemaRouter() {
163
173
  req.params.field,
164
174
  { destructive: destructiveRequested(req) },
165
175
  );
176
+ clearSchemaCache();
166
177
  await auditSchema(req, {
167
178
  action: 'schema.field.delete',
168
179
  collection: req.params.collection,
@@ -197,6 +208,7 @@ export function createSchemaRouter() {
197
208
 
198
209
  router.post('/relations/m2o', async (req, res) => {
199
210
  const data = await service(req, 'RelationsService').createM2O(req.body ?? {});
211
+ clearSchemaCache();
200
212
  await auditSchema(req, {
201
213
  action: 'schema.relation.create',
202
214
  collection: data.many_collection,
@@ -213,6 +225,7 @@ export function createSchemaRouter() {
213
225
  req.params.manyCollection,
214
226
  req.params.manyField,
215
227
  );
228
+ clearSchemaCache();
216
229
  await auditSchema(req, {
217
230
  action: 'schema.relation.delete',
218
231
  collection: req.params.manyCollection,
@@ -224,6 +237,7 @@ export function createSchemaRouter() {
224
237
 
225
238
  router.post('/relations/m2m', async (req, res) => {
226
239
  const data = await service(req, 'RelationsService').createM2M(req.body ?? {});
240
+ clearSchemaCache();
227
241
  await auditSchema(req, {
228
242
  action: 'schema.relation.m2m.create',
229
243
  collection: data.junctionCollection,
@@ -244,6 +258,7 @@ export function createSchemaRouter() {
244
258
  junctionCollection: req.params.junctionCollection,
245
259
  destructive: destructiveRequested(req),
246
260
  });
261
+ clearSchemaCache();
247
262
  await auditSchema(req, {
248
263
  action: 'schema.relation.m2m.delete',
249
264
  collection: req.params.junctionCollection,
@@ -0,0 +1,22 @@
1
+ import express from 'express';
2
+
3
+ import { serviceOptionsFromRequest } from '../service-options.js';
4
+
5
+ function settingsService(req) {
6
+ const Service = req.context.services.StudioSettingsService;
7
+ return new Service(serviceOptionsFromRequest(req));
8
+ }
9
+
10
+ export function createStudioSettingsRouter() {
11
+ const router = express.Router();
12
+
13
+ router.get('/', async (req, res) => {
14
+ res.json({ data: await settingsService(req).readPublic() });
15
+ });
16
+
17
+ router.patch('/', async (req, res) => {
18
+ res.json({ data: await settingsService(req).updateOne(req.body ?? {}) });
19
+ });
20
+
21
+ return router;
22
+ }