@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 +21 -0
- package/README.md +5 -0
- package/package.json +38 -0
- package/src/app.js +130 -0
- package/src/authentication.js +91 -0
- package/src/error-response.js +143 -0
- package/src/extensions/discovery.js +144 -0
- package/src/extensions/manifest.js +65 -0
- package/src/extensions/runtime.js +121 -0
- package/src/rate-limit.js +54 -0
- package/src/routes/audit.js +45 -0
- package/src/routes/auth.js +179 -0
- package/src/routes/files.js +98 -0
- package/src/routes/items.js +75 -0
- package/src/routes/permissions.js +45 -0
- package/src/routes/roles.js +45 -0
- package/src/routes/schema.js +259 -0
- package/src/routes/users.js +50 -0
- package/src/server.js +182 -0
- package/src/service-options.js +14 -0
- package/src/studio.js +80 -0
- package/studio-dist/assets/index-B-brC-Im.css +1 -0
- package/studio-dist/assets/index-D3C1Fv1L.js +9 -0
- package/studio-dist/index.html +14 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { deleteM2MJunction } from '@yunsoft/yuncms-core';
|
|
3
|
+
|
|
4
|
+
import { serviceOptionsFromRequest } from '../service-options.js';
|
|
5
|
+
|
|
6
|
+
function service(req, name) {
|
|
7
|
+
const Service = req.context.services[name];
|
|
8
|
+
if (!Service) throw new Error(`Service is not registered: ${name}`);
|
|
9
|
+
return new Service(serviceOptionsFromRequest(req));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function destructiveRequested(req) {
|
|
13
|
+
return String(req.query?.destructive ?? '').toLowerCase() === 'true';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function auditSchema(req, { action, collection = null, itemKey = null, payload = null }) {
|
|
17
|
+
try {
|
|
18
|
+
await service(req, 'AuditService').record({
|
|
19
|
+
action,
|
|
20
|
+
collection,
|
|
21
|
+
itemKey,
|
|
22
|
+
requestId: req.id,
|
|
23
|
+
payload,
|
|
24
|
+
});
|
|
25
|
+
} catch (error) {
|
|
26
|
+
req.context.logger?.error?.('YunCMS schema audit write failed after committed mutation', {
|
|
27
|
+
requestId: req.id,
|
|
28
|
+
action,
|
|
29
|
+
collection,
|
|
30
|
+
itemKey,
|
|
31
|
+
code: error?.code,
|
|
32
|
+
message: error?.message,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createSchemaRouter() {
|
|
38
|
+
const router = express.Router();
|
|
39
|
+
|
|
40
|
+
router.get('/collections', async (req, res) => {
|
|
41
|
+
res.json({ data: await service(req, 'CollectionsService').readMany() });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
router.post('/collections', async (req, res) => {
|
|
45
|
+
const data = await service(req, 'CollectionsService').createOne(req.body ?? {});
|
|
46
|
+
await auditSchema(req, {
|
|
47
|
+
action: 'schema.collection.create',
|
|
48
|
+
collection: data.collection,
|
|
49
|
+
itemKey: data.collection,
|
|
50
|
+
payload: { after: data },
|
|
51
|
+
});
|
|
52
|
+
res.status(201).json({ data });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
router.get('/collections/:collection', async (req, res) => {
|
|
56
|
+
const data = await service(req, 'CollectionsService').readOne(req.params.collection);
|
|
57
|
+
if (!data) {
|
|
58
|
+
const error = new Error(`Collection not found: ${req.params.collection}`);
|
|
59
|
+
error.code = 'COLLECTION_NOT_FOUND';
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
res.json({ data });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
router.patch('/collections/:collection', async (req, res) => {
|
|
66
|
+
const collections = service(req, 'CollectionsService');
|
|
67
|
+
const before = await collections.readOne(req.params.collection);
|
|
68
|
+
const data = await collections.updateOne(req.params.collection, req.body ?? {});
|
|
69
|
+
await auditSchema(req, {
|
|
70
|
+
action: 'schema.collection.update',
|
|
71
|
+
collection: req.params.collection,
|
|
72
|
+
itemKey: req.params.collection,
|
|
73
|
+
payload: { before, after: data, changes: req.body ?? {} },
|
|
74
|
+
});
|
|
75
|
+
res.json({ data });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
router.delete('/collections/:collection', async (req, res) => {
|
|
79
|
+
const collections = service(req, 'CollectionsService');
|
|
80
|
+
const before = await collections.readOne(req.params.collection);
|
|
81
|
+
await collections.deleteOne(req.params.collection, {
|
|
82
|
+
destructive: destructiveRequested(req),
|
|
83
|
+
});
|
|
84
|
+
await auditSchema(req, {
|
|
85
|
+
action: 'schema.collection.delete',
|
|
86
|
+
collection: req.params.collection,
|
|
87
|
+
itemKey: req.params.collection,
|
|
88
|
+
payload: { before },
|
|
89
|
+
});
|
|
90
|
+
res.status(204).end();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
router.get('/collections/:collection/fields', async (req, res) => {
|
|
94
|
+
res.json({ data: await service(req, 'FieldsService').readMany(req.params.collection) });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
router.post('/collections/:collection/fields', async (req, res) => {
|
|
98
|
+
const data = await service(req, 'FieldsService').createOne(
|
|
99
|
+
req.params.collection,
|
|
100
|
+
req.body ?? {},
|
|
101
|
+
);
|
|
102
|
+
await auditSchema(req, {
|
|
103
|
+
action: 'schema.field.create',
|
|
104
|
+
collection: req.params.collection,
|
|
105
|
+
itemKey: data.field,
|
|
106
|
+
payload: { after: data },
|
|
107
|
+
});
|
|
108
|
+
res.status(201).json({ data });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
router.get('/collections/:collection/fields/:field', async (req, res) => {
|
|
112
|
+
const data = await service(req, 'FieldsService').readOne(
|
|
113
|
+
req.params.collection,
|
|
114
|
+
req.params.field,
|
|
115
|
+
);
|
|
116
|
+
if (!data) {
|
|
117
|
+
const error = new Error(`Field not found: ${req.params.collection}.${req.params.field}`);
|
|
118
|
+
error.code = 'FIELD_NOT_FOUND';
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
res.json({ data });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
router.patch('/collections/:collection/fields/:field', async (req, res) => {
|
|
125
|
+
const fields = service(req, 'FieldsService');
|
|
126
|
+
const before = await fields.readOne(req.params.collection, req.params.field);
|
|
127
|
+
const data = await fields.updateOne(
|
|
128
|
+
req.params.collection,
|
|
129
|
+
req.params.field,
|
|
130
|
+
req.body ?? {},
|
|
131
|
+
);
|
|
132
|
+
await auditSchema(req, {
|
|
133
|
+
action: 'schema.field.update',
|
|
134
|
+
collection: req.params.collection,
|
|
135
|
+
itemKey: req.params.field,
|
|
136
|
+
payload: { before, after: data, changes: req.body ?? {} },
|
|
137
|
+
});
|
|
138
|
+
res.json({ data });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
router.patch('/collections/:collection/fields/:field/schema', async (req, res) => {
|
|
142
|
+
const fields = service(req, 'FieldsService');
|
|
143
|
+
const before = await fields.readOne(req.params.collection, req.params.field);
|
|
144
|
+
const data = await fields.updateSchema(
|
|
145
|
+
req.params.collection,
|
|
146
|
+
req.params.field,
|
|
147
|
+
req.body ?? {},
|
|
148
|
+
);
|
|
149
|
+
await auditSchema(req, {
|
|
150
|
+
action: 'schema.field.alter',
|
|
151
|
+
collection: req.params.collection,
|
|
152
|
+
itemKey: req.params.field,
|
|
153
|
+
payload: { before, after: data, changes: req.body ?? {} },
|
|
154
|
+
});
|
|
155
|
+
res.json({ data });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
router.delete('/collections/:collection/fields/:field', async (req, res) => {
|
|
159
|
+
const fields = service(req, 'FieldsService');
|
|
160
|
+
const before = await fields.readOne(req.params.collection, req.params.field);
|
|
161
|
+
await fields.deleteOne(
|
|
162
|
+
req.params.collection,
|
|
163
|
+
req.params.field,
|
|
164
|
+
{ destructive: destructiveRequested(req) },
|
|
165
|
+
);
|
|
166
|
+
await auditSchema(req, {
|
|
167
|
+
action: 'schema.field.delete',
|
|
168
|
+
collection: req.params.collection,
|
|
169
|
+
itemKey: req.params.field,
|
|
170
|
+
payload: { before },
|
|
171
|
+
});
|
|
172
|
+
res.status(204).end();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
router.get('/relations', async (req, res) => {
|
|
176
|
+
res.json({ data: await service(req, 'RelationsService').readMany() });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
router.get('/relations/:manyCollection/:manyField', async (req, res) => {
|
|
180
|
+
const data = await service(req, 'RelationsService').readOne(
|
|
181
|
+
req.params.manyCollection,
|
|
182
|
+
req.params.manyField,
|
|
183
|
+
);
|
|
184
|
+
if (!data) {
|
|
185
|
+
const error = new Error(
|
|
186
|
+
`Relation not found: ${req.params.manyCollection}.${req.params.manyField}`,
|
|
187
|
+
);
|
|
188
|
+
error.code = 'RELATION_NOT_FOUND';
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
res.json({ data });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
router.get('/collections/:collection/relations/o2m', async (req, res) => {
|
|
195
|
+
res.json({ data: await service(req, 'RelationsService').readO2M(req.params.collection) });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
router.post('/relations/m2o', async (req, res) => {
|
|
199
|
+
const data = await service(req, 'RelationsService').createM2O(req.body ?? {});
|
|
200
|
+
await auditSchema(req, {
|
|
201
|
+
action: 'schema.relation.create',
|
|
202
|
+
collection: data.many_collection,
|
|
203
|
+
itemKey: data.many_field,
|
|
204
|
+
payload: { after: data },
|
|
205
|
+
});
|
|
206
|
+
res.status(201).json({ data });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
router.delete('/relations/m2o/:manyCollection/:manyField', async (req, res) => {
|
|
210
|
+
const relations = service(req, 'RelationsService');
|
|
211
|
+
const before = await relations.readOne(req.params.manyCollection, req.params.manyField);
|
|
212
|
+
await relations.deleteM2O(
|
|
213
|
+
req.params.manyCollection,
|
|
214
|
+
req.params.manyField,
|
|
215
|
+
);
|
|
216
|
+
await auditSchema(req, {
|
|
217
|
+
action: 'schema.relation.delete',
|
|
218
|
+
collection: req.params.manyCollection,
|
|
219
|
+
itemKey: req.params.manyField,
|
|
220
|
+
payload: { before },
|
|
221
|
+
});
|
|
222
|
+
res.status(204).end();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
router.post('/relations/m2m', async (req, res) => {
|
|
226
|
+
const data = await service(req, 'RelationsService').createM2M(req.body ?? {});
|
|
227
|
+
await auditSchema(req, {
|
|
228
|
+
action: 'schema.relation.m2m.create',
|
|
229
|
+
collection: data.junctionCollection,
|
|
230
|
+
itemKey: data.junctionCollection,
|
|
231
|
+
payload: { after: data },
|
|
232
|
+
});
|
|
233
|
+
res.status(201).json({ data });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
router.delete('/relations/m2m/:junctionCollection', async (req, res) => {
|
|
237
|
+
const relationRows = await service(req, 'RelationsService').readMany();
|
|
238
|
+
const before = relationRows.filter(
|
|
239
|
+
(relation) => relation.junction_collection === req.params.junctionCollection,
|
|
240
|
+
);
|
|
241
|
+
const data = await deleteM2MJunction({
|
|
242
|
+
database: req.context.database,
|
|
243
|
+
accountability: req.accountability,
|
|
244
|
+
junctionCollection: req.params.junctionCollection,
|
|
245
|
+
destructive: destructiveRequested(req),
|
|
246
|
+
});
|
|
247
|
+
await auditSchema(req, {
|
|
248
|
+
action: 'schema.relation.m2m.delete',
|
|
249
|
+
collection: req.params.junctionCollection,
|
|
250
|
+
itemKey: req.params.junctionCollection,
|
|
251
|
+
payload: { before, result: data },
|
|
252
|
+
});
|
|
253
|
+
res.status(204).end();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return router;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export { auditSchema, destructiveRequested };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
import { serviceOptionsFromRequest } from '../service-options.js';
|
|
4
|
+
|
|
5
|
+
function usersService(req) {
|
|
6
|
+
const Service = req.context.services.UsersService;
|
|
7
|
+
return new Service(serviceOptionsFromRequest(req));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function notFound(id) {
|
|
11
|
+
const error = new Error(`User not found: ${id}`);
|
|
12
|
+
error.code = 'USER_NOT_FOUND';
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createUsersRouter() {
|
|
17
|
+
const router = express.Router();
|
|
18
|
+
|
|
19
|
+
router.get('/', async (req, res) => {
|
|
20
|
+
res.json({ data: await usersService(req).readMany() });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
router.post('/', async (req, res) => {
|
|
24
|
+
const data = await usersService(req).createOne(req.body ?? {});
|
|
25
|
+
res.status(201).json({ data });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
router.get('/:id', async (req, res) => {
|
|
29
|
+
const data = await usersService(req).readOne(req.params.id);
|
|
30
|
+
if (!data) throw notFound(req.params.id);
|
|
31
|
+
res.json({ data });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
router.patch('/:id', async (req, res) => {
|
|
35
|
+
const data = await usersService(req).updateOne(req.params.id, req.body ?? {});
|
|
36
|
+
res.json({ data });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
router.patch('/:id/password', async (req, res) => {
|
|
40
|
+
await usersService(req).updatePassword(req.params.id, req.body?.password);
|
|
41
|
+
res.status(204).end();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
router.delete('/:id', async (req, res) => {
|
|
45
|
+
await usersService(req).deleteOne(req.params.id);
|
|
46
|
+
res.status(204).end();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return router;
|
|
50
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertDatabaseCompatible,
|
|
3
|
+
closeDatabasePool,
|
|
4
|
+
createCoreServiceRegistry,
|
|
5
|
+
createDatabasePool,
|
|
6
|
+
createJsonLogger,
|
|
7
|
+
createStorageRegistry,
|
|
8
|
+
createSystemAccountability,
|
|
9
|
+
HookEmitter,
|
|
10
|
+
loadConfig,
|
|
11
|
+
loadEnvFileIfPresent,
|
|
12
|
+
LocalStorageDriver,
|
|
13
|
+
S3StorageDriver,
|
|
14
|
+
SchemaCache,
|
|
15
|
+
SmtpMailer,
|
|
16
|
+
} from '@yunsoft/yuncms-core';
|
|
17
|
+
import { createApp } from './app.js';
|
|
18
|
+
import { loadExtensionRuntime } from './extensions/runtime.js';
|
|
19
|
+
|
|
20
|
+
loadEnvFileIfPresent();
|
|
21
|
+
const config = loadConfig();
|
|
22
|
+
const logger = createJsonLogger({ level: config.logging.level });
|
|
23
|
+
const pool = createDatabasePool(config.database);
|
|
24
|
+
const storageDrivers = {
|
|
25
|
+
local: new LocalStorageDriver({ root: config.storage.localRoot }),
|
|
26
|
+
};
|
|
27
|
+
if (config.storage.s3.bucket) {
|
|
28
|
+
storageDrivers.s3 = new S3StorageDriver({
|
|
29
|
+
bucket: config.storage.s3.bucket,
|
|
30
|
+
region: config.storage.s3.region,
|
|
31
|
+
endpoint: config.storage.s3.endpoint ?? undefined,
|
|
32
|
+
accessKeyId: config.storage.s3.accessKeyId ?? undefined,
|
|
33
|
+
secretAccessKey: config.storage.s3.secretAccessKey ?? undefined,
|
|
34
|
+
forcePathStyle: config.storage.s3.forcePathStyle,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const storage = createStorageRegistry(storageDrivers);
|
|
38
|
+
|
|
39
|
+
const hasAnyMailConfig = Boolean(
|
|
40
|
+
config.mail.host || config.mail.from || config.mail.user || config.mail.password,
|
|
41
|
+
);
|
|
42
|
+
if (hasAnyMailConfig && (!config.mail.host || !config.mail.from)) {
|
|
43
|
+
throw new Error('SMTP_HOST and SMTP_FROM are both required when SMTP delivery is configured');
|
|
44
|
+
}
|
|
45
|
+
const mailer = config.mail.host
|
|
46
|
+
? new SmtpMailer({
|
|
47
|
+
host: config.mail.host,
|
|
48
|
+
port: config.mail.port,
|
|
49
|
+
secure: config.mail.secure,
|
|
50
|
+
user: config.mail.user,
|
|
51
|
+
password: config.mail.password,
|
|
52
|
+
from: config.mail.from,
|
|
53
|
+
})
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
let server = null;
|
|
57
|
+
let shuttingDown = false;
|
|
58
|
+
|
|
59
|
+
function registerInternalAudit({ emitter, services }) {
|
|
60
|
+
const AuditService = services.AuditService;
|
|
61
|
+
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
|
+
|
|
71
|
+
for (const event of events) {
|
|
72
|
+
emitter.registerAction(event, async (payload, context) => {
|
|
73
|
+
try {
|
|
74
|
+
const audit = new AuditService({
|
|
75
|
+
accountability: systemAccountability,
|
|
76
|
+
database: pool,
|
|
77
|
+
logger,
|
|
78
|
+
requestId: context.requestId ?? null,
|
|
79
|
+
});
|
|
80
|
+
await audit.record({
|
|
81
|
+
user: context.accountability?.user ?? null,
|
|
82
|
+
action: event,
|
|
83
|
+
collection: context.collection ?? null,
|
|
84
|
+
itemKey: payload?.key ?? null,
|
|
85
|
+
requestId: context.requestId ?? null,
|
|
86
|
+
payload,
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
logger.error('YunCMS audit write failed after committed mutation', {
|
|
90
|
+
event,
|
|
91
|
+
requestId: context.requestId ?? null,
|
|
92
|
+
code: error?.code,
|
|
93
|
+
error,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function start() {
|
|
101
|
+
await assertDatabaseCompatible(pool);
|
|
102
|
+
|
|
103
|
+
const serviceRegistry = createCoreServiceRegistry();
|
|
104
|
+
const services = serviceRegistry.toObject();
|
|
105
|
+
const schemaCache = new SchemaCache();
|
|
106
|
+
const emitter = new HookEmitter();
|
|
107
|
+
registerInternalAudit({ emitter, services });
|
|
108
|
+
|
|
109
|
+
const extensionRuntime = await loadExtensionRuntime({
|
|
110
|
+
services,
|
|
111
|
+
database: pool,
|
|
112
|
+
schemaCache,
|
|
113
|
+
emitter,
|
|
114
|
+
storage,
|
|
115
|
+
logger,
|
|
116
|
+
env: config,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const app = createApp({
|
|
120
|
+
pool,
|
|
121
|
+
config,
|
|
122
|
+
logger,
|
|
123
|
+
serviceRegistry,
|
|
124
|
+
schemaCache,
|
|
125
|
+
emitter,
|
|
126
|
+
storage,
|
|
127
|
+
mailer,
|
|
128
|
+
endpointExtensions: extensionRuntime.endpointExtensions,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await extensionRuntime.init('app.beforeStart');
|
|
132
|
+
server = await new Promise((resolve, reject) => {
|
|
133
|
+
const listeningServer = app.listen(config.server.port, config.server.host, () => {
|
|
134
|
+
logger.info('YunCMS API listening', {
|
|
135
|
+
host: config.server.host,
|
|
136
|
+
port: config.server.port,
|
|
137
|
+
});
|
|
138
|
+
resolve(listeningServer);
|
|
139
|
+
});
|
|
140
|
+
listeningServer.once('error', reject);
|
|
141
|
+
});
|
|
142
|
+
await extensionRuntime.init('app.afterStart');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function shutdown(signal) {
|
|
146
|
+
if (shuttingDown) return;
|
|
147
|
+
shuttingDown = true;
|
|
148
|
+
logger.info('YunCMS API shutting down', { signal });
|
|
149
|
+
|
|
150
|
+
const forceExit = setTimeout(() => {
|
|
151
|
+
logger.error('Graceful shutdown timed out', { signal });
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}, 10_000);
|
|
154
|
+
forceExit.unref();
|
|
155
|
+
|
|
156
|
+
if (server) {
|
|
157
|
+
await new Promise((resolve) => server.close(resolve));
|
|
158
|
+
}
|
|
159
|
+
await closeDatabasePool(pool);
|
|
160
|
+
clearTimeout(forceExit);
|
|
161
|
+
logger.info('YunCMS API shutdown complete', { signal });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
165
|
+
process.once(signal, () => {
|
|
166
|
+
shutdown(signal)
|
|
167
|
+
.then(() => process.exit(0))
|
|
168
|
+
.catch((error) => {
|
|
169
|
+
logger.error('Graceful shutdown failed', { signal, error });
|
|
170
|
+
process.exit(1);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
start().catch(async (error) => {
|
|
176
|
+
logger.error('YunCMS API failed to start', {
|
|
177
|
+
code: error?.code,
|
|
178
|
+
error,
|
|
179
|
+
});
|
|
180
|
+
await closeDatabasePool(pool).catch(() => {});
|
|
181
|
+
process.exit(1);
|
|
182
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function serviceOptionsFromRequest(req) {
|
|
2
|
+
if (!req?.context) throw new Error('Request context is required');
|
|
3
|
+
|
|
4
|
+
return {
|
|
5
|
+
accountability: req.accountability,
|
|
6
|
+
database: req.context.database,
|
|
7
|
+
schema: req.context.schema,
|
|
8
|
+
logger: req.context.logger,
|
|
9
|
+
emitter: req.context.emitter,
|
|
10
|
+
storage: req.context.storage,
|
|
11
|
+
permissionCache: req.context.permissionCache,
|
|
12
|
+
requestId: req.id,
|
|
13
|
+
};
|
|
14
|
+
}
|
package/src/studio.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { extname, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_STUDIO_ROOT = fileURLToPath(new URL('../studio-dist/', import.meta.url));
|
|
7
|
+
|
|
8
|
+
const CONTENT_TYPES = new Map([
|
|
9
|
+
['.css', 'text/css; charset=utf-8'],
|
|
10
|
+
['.gif', 'image/gif'],
|
|
11
|
+
['.html', 'text/html; charset=utf-8'],
|
|
12
|
+
['.ico', 'image/x-icon'],
|
|
13
|
+
['.jpeg', 'image/jpeg'],
|
|
14
|
+
['.jpg', 'image/jpeg'],
|
|
15
|
+
['.js', 'text/javascript; charset=utf-8'],
|
|
16
|
+
['.json', 'application/json; charset=utf-8'],
|
|
17
|
+
['.map', 'application/json; charset=utf-8'],
|
|
18
|
+
['.png', 'image/png'],
|
|
19
|
+
['.svg', 'image/svg+xml'],
|
|
20
|
+
['.webp', 'image/webp'],
|
|
21
|
+
['.woff', 'font/woff'],
|
|
22
|
+
['.woff2', 'font/woff2'],
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export function resolveStudioFile(root, requestPath) {
|
|
26
|
+
if (requestPath === '/') return resolve(root, 'index.html');
|
|
27
|
+
if (!requestPath.startsWith('/assets/')) return null;
|
|
28
|
+
|
|
29
|
+
let relativePath;
|
|
30
|
+
try {
|
|
31
|
+
relativePath = decodeURIComponent(requestPath.slice(1));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const assetsRoot = resolve(root, 'assets');
|
|
37
|
+
const candidate = resolve(root, relativePath);
|
|
38
|
+
if (candidate !== assetsRoot && !candidate.startsWith(`${assetsRoot}${sep}`)) return null;
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function contentTypeFor(filePath) {
|
|
43
|
+
return CONTENT_TYPES.get(extname(filePath).toLowerCase()) || 'application/octet-stream';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createStudioMiddleware({ root = DEFAULT_STUDIO_ROOT } = {}) {
|
|
47
|
+
return async function serveStudio(req, res, next) {
|
|
48
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
49
|
+
|
|
50
|
+
const filePath = resolveStudioFile(root, req.path);
|
|
51
|
+
if (!filePath) return next();
|
|
52
|
+
|
|
53
|
+
let fileStat;
|
|
54
|
+
try {
|
|
55
|
+
fileStat = await stat(filePath);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error?.code !== 'ENOENT') return next(error);
|
|
58
|
+
if (req.path === '/') {
|
|
59
|
+
return res.status(503).type('text/plain').send(
|
|
60
|
+
'YunCMS Studio build is missing. Build @yunsoft/yuncms-studio before starting the API.',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return res.status(404).end();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!fileStat.isFile()) return res.status(404).end();
|
|
67
|
+
|
|
68
|
+
res.set('content-type', contentTypeFor(filePath));
|
|
69
|
+
res.set('content-length', String(fileStat.size));
|
|
70
|
+
res.set('cache-control', req.path === '/'
|
|
71
|
+
? 'no-cache'
|
|
72
|
+
: 'public, max-age=31536000, immutable');
|
|
73
|
+
|
|
74
|
+
if (req.method === 'HEAD') return res.status(200).end();
|
|
75
|
+
|
|
76
|
+
const stream = createReadStream(filePath);
|
|
77
|
+
stream.on('error', next);
|
|
78
|
+
return stream.pipe(res);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{color:#172033;font-synthesis:none;text-rendering:optimizelegibility;background:#f5f7fb;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}body{min-width:320px;min-height:100vh;margin:0}button,input,select,textarea{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.48}input,select,textarea{color:#172033;background:#fff;border:1px solid #d9e0ea;border-radius:9px;outline:none;width:100%;padding:10px 11px}input:focus,select:focus,textarea:focus,button:focus-visible{border-color:#7a879b;box-shadow:0 0 0 3px #5c6a8224}textarea{resize:vertical}code{overflow-wrap:anywhere;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px}.auth-layout{background:#f5f7fb;place-items:center;min-height:100vh;padding:24px;display:grid}.auth-card{background:#fff;border:1px solid #e1e6ef;border-radius:16px;gap:28px;width:min(440px,100%);padding:34px;display:grid;box-shadow:0 18px 55px #202d4214}.studio-shell{grid-template-columns:264px minmax(0,1fr);min-height:100vh;display:grid}.sidebar{background:#fff;border-right:1px solid #e1e6ef;flex-direction:column;align-self:start;min-height:100vh;padding:26px 16px 18px;display:flex;position:sticky;top:0}.brand{letter-spacing:-.04em;padding-inline:8px;font-size:22px;font-weight:750}.brand-subtitle{color:#7a8498;margin-top:2px;padding-inline:8px;font-size:13px}nav{gap:6px;margin-top:34px;display:grid}.nav-item,.list-button{color:#647089;text-align:left;background:0 0;border:0;border-radius:9px;justify-content:space-between;align-items:center;gap:12px;width:100%;display:flex}.nav-item{padding:10px 12px}.list-button{padding:9px 10px}.nav-item:hover,.list-button:hover{color:#182033;background:#f4f6fa}.nav-item.active,.list-button.active{color:#182033;background:#eef2f8;font-weight:650}.nav-item small,.list-button small{color:#9aa3b4;font-size:10px}.sidebar-account{overflow-wrap:anywhere;border-top:1px solid #edf0f5;gap:5px;margin-top:auto;padding-top:24px;display:grid}.sidebar-account strong{font-size:13px}.sidebar-account small{color:#7a8498;font-size:11px}.sidebar-account .text-button{justify-self:start;margin-top:5px}.main-content{min-width:0;padding:40px 44px 56px}.page-header{justify-content:space-between;align-items:flex-start;gap:28px;margin-bottom:26px;display:flex}.header-statuses{justify-items:end;gap:7px;display:grid}.api-address{color:#8892a5;overflow-wrap:anywhere;text-align:right;max-width:260px}.eyebrow{color:#6d7890;letter-spacing:.08em;text-transform:uppercase;margin:0 0 7px;font-size:12px;font-weight:700}h1,h2,p{margin-top:0}h1{letter-spacing:-.045em;margin-bottom:10px;font-size:clamp(30px,5vw,46px)}h2{letter-spacing:-.025em;margin-bottom:8px;font-size:22px}.lede,.panel p,.metric-card small,.muted-line{color:#717c91;line-height:1.6}.lede{max-width:720px;margin-bottom:0}.status{white-space:nowrap;background:#fff;border:1px solid #dfe5ee;border-radius:999px;align-items:center;gap:8px;padding:9px 12px;font-size:13px;display:inline-flex}.status-dot{background:#9da6b7;border-radius:50%;flex:none;width:8px;height:8px}.status.online .status-dot,.sidebar-health.online .status-dot{background:#2f9e62}.status.offline .status-dot,.sidebar-health.offline .status-dot{background:#c84c4c}.screen-stack,.form-stack,.list-stack{display:grid}.screen-stack{gap:16px}.form-stack{gap:15px}.form-stack.compact{gap:11px}.list-stack{gap:6px}.panel,.table-panel{background:#fff;border:1px solid #e1e6ef;border-radius:14px}.panel{justify-content:space-between;align-items:center;gap:20px;padding:28px;display:flex}.panel p{margin-bottom:0}.form-panel{justify-content:stretch;align-items:stretch;gap:22px;display:grid}.panel-heading,.toolbar-panel,.toolbar-actions,.form-actions,.row-actions,.relation-row{align-items:center;display:flex}.panel-heading,.toolbar-panel,.relation-row{justify-content:space-between}.panel-heading,.toolbar-panel{gap:18px}.toolbar-actions,.form-actions,.row-actions{gap:8px}.toolbar-actions select{min-width:190px}.split-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;display:grid}.form-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:15px;display:grid}.inline-form{align-items:end}.field-label{color:#4c586e;gap:7px;font-size:13px;font-weight:650;display:grid}.checkbox-label{color:#4c586e;align-items:center;gap:8px;font-size:13px;font-weight:650;display:flex}.checkbox-label input,.field-label input[type=checkbox]{width:auto}.primary-button,.text-button,.danger-button{border-radius:9px;font-weight:650}.primary-button{color:#fff;background:#1d2738;border:0;padding:10px 14px}.primary-button:hover:not(:disabled){background:#111a28}.text-button,.danger-button{background:0 0;border:1px solid #0000;padding:7px 9px}.text-button{color:#40506a}.text-button:hover:not(:disabled){background:#eef2f8}.danger-button{color:#aa3f3f}.danger-button:hover:not(:disabled){background:#fff4f4;border-color:#f0d0d0}.error-banner,.notice-banner{border-radius:10px;padding:12px 14px;font-size:13px}.error-banner{color:#8e3030;background:#fff2f2;border:1px solid #efc9c9}.notice-banner{color:#2e7047;background:#f1faf4;border:1px solid #cfe5d7}.table-panel{min-width:0;overflow:hidden}.table-scroll{overflow:auto}.compact-table{border:1px solid #edf0f5;border-radius:10px;max-height:420px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;vertical-align:middle;border-bottom:1px solid #edf0f5;padding:12px 14px}th{z-index:1;color:#67738a;letter-spacing:.04em;text-transform:uppercase;background:#fafbfd;font-size:11px;position:sticky;top:0}tbody tr:last-child td{border-bottom:0}tbody tr:hover{background:#fbfcfe}td select{min-width:130px}.row-actions{white-space:nowrap;justify-content:flex-end}.table-footer{color:#7a8498;border-top:1px solid #edf0f5;padding:11px 14px;font-size:12px}.empty-state{min-height:170px}.relation-row{border-bottom:1px solid #edf0f5;gap:14px;padding:11px 0}.relation-row:last-child{border-bottom:0}.relation-row>div:first-child{gap:3px;min-width:0;display:grid}.relation-row small{color:#7c879a;overflow-wrap:anywhere}.inline-note{color:#657087;text-transform:uppercase;background:#eef2f8;border-radius:999px;margin-left:7px;padding:2px 6px;font-size:10px;font-weight:700;display:inline-block}.grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:16px;display:grid}.metric-card{background:#fff;border:1px solid #e1e6ef;border-radius:12px;gap:8px;min-width:0;padding:20px;display:grid}.metric-card>span{color:#7b8598;text-transform:uppercase;font-size:12px;font-weight:700}.metric-card strong{font-size:17px}.metric-card small{overflow-wrap:anywhere}.sidebar-nav{gap:22px;margin-top:30px;display:grid}.nav-group{gap:5px;display:grid}.nav-group-label{color:#9aa3b4;letter-spacing:.1em;text-transform:uppercase;padding:0 10px 4px;font-size:10px;font-weight:750}.nav-children{gap:3px;display:grid}.nav-item-child{padding-left:25px;position:relative}.nav-item-child:before{content:"";background:#b4bdcb;border-radius:50%;width:4px;height:4px;position:absolute;left:12px}.nav-item-child.active:before{background:#485872}.sidebar-footer{border-top:1px solid #edf0f5;gap:8px;margin-top:auto;padding:18px 8px 0;display:grid}.sidebar-health{color:#68758b;align-items:center;gap:7px;font-size:11px;display:flex}.sidebar-api-address{color:#a0a8b6;overflow-wrap:anywhere;font-size:10px;line-height:1.4}.sidebar-footer .sidebar-account{margin-top:8px;padding-top:14px}.content-toolbar-actions,.library-toolbar-actions{flex-wrap:wrap;justify-content:flex-end}.search-input{min-width:220px;max-width:320px}.empty-state-action{align-items:center}.record-editor{max-width:1040px}.file-upload-panel{align-items:stretch}.file-upload-panel>div:first-child{max-width:340px}.file-dropzone{background:#fafbfd;border:1px dashed #c8d1df;border-radius:12px;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;min-width:min(520px,100%);padding:14px;transition:border-color .12s,background .12s;display:grid}.file-dropzone.active{background:#f1f4f8;border-color:#7f8ca1}.file-dropzone input[type=file]{opacity:0;pointer-events:none;width:1px;height:1px;padding:0;position:absolute}.file-picker-label{cursor:pointer;gap:3px;min-width:0;padding:3px 4px;display:grid}.file-picker-label strong{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.file-picker-label span{color:#7b8598;font-size:12px}.file-editor{border-color:#d7dee8;box-shadow:0 10px 30px #202d420d}.library-toolbar{padding-block:20px}.segmented-control{background:#f7f8fb;border:1px solid #dfe5ee;border-radius:10px;padding:3px;display:inline-flex}.segmented-control button{color:#657087;background:0 0;border:0;border-radius:7px;padding:7px 10px;font-size:12px;font-weight:650}.segmented-control button.active{color:#1d2738;background:#fff;box-shadow:0 1px 4px #202d421a}.file-grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px;display:grid}.file-card{background:#fff;border:1px solid #e1e6ef;border-radius:14px;min-width:0;transition:transform .12s,box-shadow .12s,border-color .12s;overflow:hidden}.file-card:hover{border-color:#d2dae6;transform:translateY(-1px);box-shadow:0 10px 28px #202d4212}.file-preview{aspect-ratio:4/3;background:#f4f6f9;border-bottom:1px solid #edf0f5;place-items:center;display:grid;overflow:hidden}.file-preview img{object-fit:cover;width:100%;height:100%}.file-type-placeholder{color:#667289;letter-spacing:.06em;background:#fff;border:1px solid #d8dfe9;border-radius:16px;place-items:center;width:64px;height:64px;font-size:12px;font-weight:800;display:grid}.file-card-body{gap:12px;padding:14px;display:grid}.file-card-title{gap:4px;min-width:0;display:grid}.file-card-title strong,.file-card-title small{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.file-card-title small,.file-meta-row{color:#7d8798;font-size:11px}.file-meta-row,.file-card-actions{justify-content:space-between;align-items:center;gap:8px;display:flex}.file-card-actions{border-top:1px solid #f0f2f6;justify-content:flex-start;padding-top:2px}.file-card-actions .danger-button{margin-left:auto}.modal-backdrop{z-index:1000;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);background:#1118278f;place-items:center;padding:24px;animation:.14s ease-out modal-backdrop-in;display:grid;position:fixed;inset:0}.modal-card{background:#fff;border:1px solid #ffffffa6;border-radius:18px;outline:none;width:min(100%,480px);max-height:calc(100vh - 48px);padding:28px;animation:.16s ease-out modal-card-in;overflow:auto;box-shadow:0 28px 72px #1118273d}.modal-heading{gap:8px;display:grid}.modal-heading h2,.modal-heading p{margin:0}.modal-heading>p:last-child{color:#667289;line-height:1.6}.modal-body{margin-top:20px}.modal-actions{justify-content:flex-end;align-items:center;gap:10px;margin-top:26px;display:flex}@keyframes modal-backdrop-in{0%{opacity:0}}@keyframes modal-card-in{0%{opacity:0;transform:translateY(8px)scale(.985)}}@media (prefers-reduced-motion:reduce){.modal-backdrop,.modal-card{animation:none}}@media (width<=1100px){.split-grid,.form-grid{grid-template-columns:1fr}.file-upload-panel{flex-direction:column;align-items:stretch}.file-upload-panel>div:first-child{max-width:none}}@media (width<=900px){.studio-shell{grid-template-columns:1fr}.sidebar{border-bottom:1px solid #e1e6ef;border-right:0;min-height:auto;position:static}nav{grid-template-columns:repeat(2,minmax(0,1fr))}.sidebar-nav{grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.sidebar-footer,.sidebar-account{margin-top:22px}.main-content{padding:28px 20px}.page-header,.panel-heading,.toolbar-panel,.panel{flex-direction:column;align-items:stretch}.header-statuses{justify-items:start}.api-address{text-align:left}.toolbar-actions{flex-direction:column;align-items:stretch}.toolbar-actions select{min-width:0}.search-input{max-width:none}.grid{grid-template-columns:1fr}.file-dropzone{min-width:0}}@media (width<=560px){.auth-card{padding:24px}nav,.sidebar-nav{grid-template-columns:1fr}.main-content{padding-inline:14px}.panel,.form-panel{padding:20px}.row-actions{flex-direction:column;align-items:flex-end}.file-dropzone,.file-grid{grid-template-columns:1fr}.file-card-actions{flex-wrap:wrap}.modal-backdrop{align-items:end;padding:12px}.modal-card{border-radius:18px 18px 12px 12px;width:100%;max-height:calc(100vh - 24px);padding:22px}.modal-actions{flex-direction:column-reverse;align-items:stretch}.modal-actions button{width:100%}}.model-layout,.permissions-layout{grid-template-columns:minmax(260px,320px) minmax(0,1fr);align-items:start;gap:16px;display:grid}.model-sidebar,.role-sidebar{position:sticky;top:20px}.model-detail-stack,.permissions-detail-stack,.schema-tab-content{gap:16px;min-width:0;display:grid}.model-detail,.advanced-permission-panel{min-width:0}.collection-group,.system-collections{gap:8px;display:grid}.collection-group-label{color:#949eae;letter-spacing:.08em;text-transform:uppercase;font-size:10px;font-weight:750}.collection-list-button,.role-list-button{align-items:flex-start;padding:10px 11px}.collection-list-button>span,.role-list-button>span{gap:3px;min-width:0;display:grid}.collection-list-button strong,.role-list-button strong{font-size:13px}.collection-list-button small,.role-list-button small{text-overflow:ellipsis;white-space:nowrap;max-width:220px;overflow:hidden}.system-collections{border-top:1px solid #edf0f5;padding-top:8px}.system-collections summary{color:#7d8798;cursor:pointer;font-size:12px;font-weight:650}.system-collections[open] summary{margin-bottom:8px}.schema-create-card{background:#fafbfd;border:1px solid #e5e9f0;border-radius:12px;padding:18px}.schema-create-card p{color:#7b8598;margin:5px 0 0;font-size:12px;line-height:1.5}.schema-tabs{justify-self:start}.schema-section-heading,.permission-matrix-heading{justify-content:space-between;align-items:flex-start;gap:16px;display:flex}.schema-section-heading h3,.schema-create-card h3,.permission-matrix-heading h2{margin:0 0 5px}.schema-section-heading p,.permission-matrix-heading p{color:#7b8598;margin:0;font-size:12px;line-height:1.5}.schema-count,.permission-count{color:#657087;white-space:nowrap;background:#eef2f8;border-radius:999px;justify-content:center;align-items:center;min-width:28px;height:24px;padding:0 8px;font-size:11px;font-weight:750;display:inline-flex}.field-list{border:1px solid #e8ecf2;border-radius:12px;display:grid;overflow:hidden}.field-row{background:#fff;border-bottom:1px solid #edf0f5;justify-content:space-between;align-items:center;gap:14px;padding:12px 14px;display:flex}.field-row:last-child{border-bottom:0}.field-row-main,.field-row-meta{align-items:center;gap:9px;display:flex}.field-row-main{min-width:0}.field-row-main strong{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.field-row-main>span{color:#748096;background:#f1f4f8;border-radius:999px;padding:3px 7px;font-size:10px;font-weight:700}.status-pill{color:#818b9d;background:#f3f5f8;border-radius:999px;padding:4px 7px;font-size:10px;font-weight:700}.status-pill.required{color:#55647d;background:#eef2f8}.schema-checkbox{align-self:end;min-height:41px}.relation-builder-grid{align-items:start}.relation-list{padding:0 2px}.inline-info{color:#68758b;background:#f8fafc;border:1px solid #dfe5ee;border-radius:10px;padding:14px 16px;font-size:13px;line-height:1.5}.role-detail-header{align-items:flex-end}.role-name-form{align-items:flex-end;gap:10px;min-width:min(520px,100%);display:flex}.role-name-form>div{flex:1;min-width:0}.role-name-field{margin:0}.permission-matrix-panel{overflow:hidden}.permission-matrix-heading{background:#fff;border-bottom:1px solid #edf0f5;padding:22px 24px}.permission-matrix th:not(:first-child),.permission-matrix td:not(:first-child){text-align:center;min-width:132px}.permission-matrix td:first-child{min-width:180px}.matrix-note{color:#9099a8;text-overflow:ellipsis;white-space:nowrap;max-width:220px;margin-top:3px;font-size:10px;display:block;overflow:hidden}.permission-cell{justify-items:center;gap:4px;display:grid}.permission-toggle{color:#5d6980;align-items:center;gap:6px;font-size:11px;font-weight:650;display:inline-flex}.permission-toggle input{width:auto}.permission-configure{padding:4px 6px;font-size:10px}.advanced-permission-grid{grid-template-columns:minmax(0,.9fr) minmax(0,1.1fr);gap:16px;display:grid}.field-choice-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;max-height:320px;display:grid;overflow:auto}.field-choice{cursor:pointer;background:#fff;border:1px solid #e5e9f0;border-radius:9px;align-items:flex-start;gap:8px;padding:9px 10px;display:flex}.field-choice input{width:auto;margin-top:2px}.field-choice>span{gap:2px;min-width:0;display:grid}.field-choice strong{text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.field-choice small,.field-label>small{color:#8791a2;font-size:10px;font-weight:500;line-height:1.45}@media (width<=1180px){.model-layout,.permissions-layout{grid-template-columns:1fr}.model-sidebar,.role-sidebar{position:static}.advanced-permission-grid{grid-template-columns:1fr}}@media (width<=760px){.field-row,.role-detail-header,.role-name-form,.schema-section-heading,.permission-matrix-heading{flex-direction:column;align-items:stretch}.field-row-meta{flex-wrap:wrap}.role-name-form{min-width:0}.field-choice-grid{grid-template-columns:1fr}}
|