@yunsoft/yuncms-api 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -3,3 +3,9 @@
3
3
  Express API runtime and bundled React Studio server for YunCMS.
4
4
 
5
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.
6
+
7
+ ## Project status
8
+
9
+ YunCMS is developed and maintained by [Yunsoft Software](https://yunsoft.com). It is under active development, so interfaces and behavior may change between releases. Test upgrades and keep verified backups before production use.
10
+
11
+ Use YunCMS at your own risk. This package is provided under the [MIT License](https://github.com/Yunsoft-Software/yuncms/blob/16-08-2026/LICENSE) without warranty.
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.2",
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.2",
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
  }
@@ -1,5 +1,9 @@
1
1
  import express from 'express';
2
- import { deleteM2MJunction } from '@yunsoft/yuncms-core';
2
+ import {
3
+ createO2ORelation,
4
+ deleteM2MJunction,
5
+ deleteO2ORelation,
6
+ } from '@yunsoft/yuncms-core';
3
7
 
4
8
  import { serviceOptionsFromRequest } from '../service-options.js';
5
9
 
@@ -34,15 +38,20 @@ async function auditSchema(req, { action, collection = null, itemKey = null, pay
34
38
  }
35
39
  }
36
40
 
37
- export function createSchemaRouter() {
41
+ export function createSchemaRouter({ schemaCache = null } = {}) {
38
42
  const router = express.Router();
39
43
 
44
+ function clearSchemaCache() {
45
+ schemaCache?.clear();
46
+ }
47
+
40
48
  router.get('/collections', async (req, res) => {
41
49
  res.json({ data: await service(req, 'CollectionsService').readMany() });
42
50
  });
43
51
 
44
52
  router.post('/collections', async (req, res) => {
45
53
  const data = await service(req, 'CollectionsService').createOne(req.body ?? {});
54
+ clearSchemaCache();
46
55
  await auditSchema(req, {
47
56
  action: 'schema.collection.create',
48
57
  collection: data.collection,
@@ -66,6 +75,7 @@ export function createSchemaRouter() {
66
75
  const collections = service(req, 'CollectionsService');
67
76
  const before = await collections.readOne(req.params.collection);
68
77
  const data = await collections.updateOne(req.params.collection, req.body ?? {});
78
+ clearSchemaCache();
69
79
  await auditSchema(req, {
70
80
  action: 'schema.collection.update',
71
81
  collection: req.params.collection,
@@ -81,6 +91,7 @@ export function createSchemaRouter() {
81
91
  await collections.deleteOne(req.params.collection, {
82
92
  destructive: destructiveRequested(req),
83
93
  });
94
+ clearSchemaCache();
84
95
  await auditSchema(req, {
85
96
  action: 'schema.collection.delete',
86
97
  collection: req.params.collection,
@@ -99,6 +110,7 @@ export function createSchemaRouter() {
99
110
  req.params.collection,
100
111
  req.body ?? {},
101
112
  );
113
+ clearSchemaCache();
102
114
  await auditSchema(req, {
103
115
  action: 'schema.field.create',
104
116
  collection: req.params.collection,
@@ -129,6 +141,7 @@ export function createSchemaRouter() {
129
141
  req.params.field,
130
142
  req.body ?? {},
131
143
  );
144
+ clearSchemaCache();
132
145
  await auditSchema(req, {
133
146
  action: 'schema.field.update',
134
147
  collection: req.params.collection,
@@ -146,6 +159,7 @@ export function createSchemaRouter() {
146
159
  req.params.field,
147
160
  req.body ?? {},
148
161
  );
162
+ clearSchemaCache();
149
163
  await auditSchema(req, {
150
164
  action: 'schema.field.alter',
151
165
  collection: req.params.collection,
@@ -163,6 +177,7 @@ export function createSchemaRouter() {
163
177
  req.params.field,
164
178
  { destructive: destructiveRequested(req) },
165
179
  );
180
+ clearSchemaCache();
166
181
  await auditSchema(req, {
167
182
  action: 'schema.field.delete',
168
183
  collection: req.params.collection,
@@ -197,6 +212,7 @@ export function createSchemaRouter() {
197
212
 
198
213
  router.post('/relations/m2o', async (req, res) => {
199
214
  const data = await service(req, 'RelationsService').createM2O(req.body ?? {});
215
+ clearSchemaCache();
200
216
  await auditSchema(req, {
201
217
  action: 'schema.relation.create',
202
218
  collection: data.many_collection,
@@ -213,6 +229,7 @@ export function createSchemaRouter() {
213
229
  req.params.manyCollection,
214
230
  req.params.manyField,
215
231
  );
232
+ clearSchemaCache();
216
233
  await auditSchema(req, {
217
234
  action: 'schema.relation.delete',
218
235
  collection: req.params.manyCollection,
@@ -222,8 +239,44 @@ export function createSchemaRouter() {
222
239
  res.status(204).end();
223
240
  });
224
241
 
242
+ router.post('/relations/o2o', async (req, res) => {
243
+ const data = await createO2ORelation({
244
+ database: req.context.database,
245
+ accountability: req.accountability,
246
+ input: req.body ?? {},
247
+ });
248
+ clearSchemaCache();
249
+ await auditSchema(req, {
250
+ action: 'schema.relation.o2o.create',
251
+ collection: data.many_collection,
252
+ itemKey: data.many_field,
253
+ payload: { after: data },
254
+ });
255
+ res.status(201).json({ data });
256
+ });
257
+
258
+ router.delete('/relations/o2o/:manyCollection/:manyField', async (req, res) => {
259
+ const relations = service(req, 'RelationsService');
260
+ const before = await relations.readOne(req.params.manyCollection, req.params.manyField);
261
+ await deleteO2ORelation({
262
+ database: req.context.database,
263
+ accountability: req.accountability,
264
+ manyCollection: req.params.manyCollection,
265
+ manyField: req.params.manyField,
266
+ });
267
+ clearSchemaCache();
268
+ await auditSchema(req, {
269
+ action: 'schema.relation.o2o.delete',
270
+ collection: req.params.manyCollection,
271
+ itemKey: req.params.manyField,
272
+ payload: { before },
273
+ });
274
+ res.status(204).end();
275
+ });
276
+
225
277
  router.post('/relations/m2m', async (req, res) => {
226
278
  const data = await service(req, 'RelationsService').createM2M(req.body ?? {});
279
+ clearSchemaCache();
227
280
  await auditSchema(req, {
228
281
  action: 'schema.relation.m2m.create',
229
282
  collection: data.junctionCollection,
@@ -244,6 +297,7 @@ export function createSchemaRouter() {
244
297
  junctionCollection: req.params.junctionCollection,
245
298
  destructive: destructiveRequested(req),
246
299
  });
300
+ clearSchemaCache();
247
301
  await auditSchema(req, {
248
302
  action: 'schema.relation.m2m.delete',
249
303
  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
+ }