@yunsoft/yuncms-api 0.1.8 → 0.1.10

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.
@@ -0,0 +1,304 @@
1
+ import {
2
+ readManyWithRelations,
3
+ readOneWithRelations,
4
+ } from '@yunsoft/yuncms-core';
5
+ import * as z from 'zod/v4';
6
+
7
+ import {
8
+ describeReadableCollection,
9
+ listReadableCollections,
10
+ } from '../mcp.js';
11
+ import { serviceOptionsFromRequest } from '../service-options.js';
12
+
13
+ const SAFE_TOOL_ERROR_CODES = new Set([
14
+ 'FORBIDDEN',
15
+ 'FORBIDDEN_FIELD',
16
+ 'COLLECTION_NOT_FOUND',
17
+ 'INVALID_QUERY',
18
+ 'QUERY_COST_LIMIT',
19
+ 'QUERY_RELATION_DEPTH_LIMIT',
20
+ 'QUERY_RELATION_ROW_LIMIT',
21
+ 'INVALID_PAYLOAD',
22
+ 'REQUIRED_FIELD_MISSING',
23
+ 'FIELD_READ_ONLY',
24
+ 'FILTER_REQUIRED',
25
+ 'VALIDATION_FAILED',
26
+ 'VALIDATION_BULK_LIMIT',
27
+ 'FOREIGN_KEY_MISSING',
28
+ 'FOREIGN_KEY_RESTRICTED',
29
+ 'DUPLICATE_KEY',
30
+ ]);
31
+
32
+ const collectionSchema = z.string()
33
+ .min(1)
34
+ .max(64)
35
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
36
+ const idSchema = z.union([z.string().min(1).max(191), z.number().finite()]);
37
+ const delimitedSchema = z.union([
38
+ z.string().min(1).max(2_000),
39
+ z.array(z.string().min(1).max(191)).max(100),
40
+ ]);
41
+ const filterSchema = z.record(z.string(), z.unknown());
42
+ const aggregateSchema = z.record(
43
+ z.string(),
44
+ z.union([z.string().min(1), z.array(z.string().min(1)).max(20)]),
45
+ );
46
+
47
+ function toolError(code, message) {
48
+ const error = new Error(message);
49
+ error.code = code;
50
+ return error;
51
+ }
52
+
53
+ function parseArguments(schema, args) {
54
+ const result = schema.safeParse(args ?? {});
55
+ if (result.success) return result.data;
56
+ throw toolError('AI_TOOL_ARGUMENTS_INVALID', 'The assistant generated invalid tool arguments');
57
+ }
58
+
59
+ function itemService(req, collection) {
60
+ const Service = req.context.services.ItemsService;
61
+ return new Service(collection, serviceOptionsFromRequest(req));
62
+ }
63
+
64
+ function readManySchema(maxItems) {
65
+ return z.object({
66
+ collection: collectionSchema,
67
+ fields: delimitedSchema.optional(),
68
+ expand: delimitedSchema.optional(),
69
+ filter: filterSchema.optional(),
70
+ search: z.string().max(200).optional(),
71
+ sort: delimitedSchema.optional(),
72
+ aggregate: aggregateSchema.optional(),
73
+ groupBy: delimitedSchema.optional(),
74
+ limit: z.number().int().min(1).max(maxItems).optional(),
75
+ offset: z.number().int().min(0).max(1_000_000).optional(),
76
+ }).strict();
77
+ }
78
+
79
+ const describeSchema = z.object({ collection: collectionSchema }).strict();
80
+ const readOneSchema = z.object({
81
+ collection: collectionSchema,
82
+ id: idSchema,
83
+ fields: delimitedSchema.optional(),
84
+ expand: delimitedSchema.optional(),
85
+ }).strict();
86
+ const createSchema = z.object({
87
+ collection: collectionSchema,
88
+ data: z.record(z.string(), z.unknown()),
89
+ }).strict();
90
+ const updateSchema = z.object({
91
+ collection: collectionSchema,
92
+ id: idSchema,
93
+ data: z.record(z.string(), z.unknown()),
94
+ }).strict();
95
+ const deleteSchema = z.object({
96
+ collection: collectionSchema,
97
+ id: idSchema,
98
+ }).strict();
99
+
100
+ function toolDefinition(name, description, parameters) {
101
+ return {
102
+ type: 'function',
103
+ function: {
104
+ name,
105
+ description,
106
+ parameters: {
107
+ additionalProperties: false,
108
+ ...parameters,
109
+ },
110
+ },
111
+ };
112
+ }
113
+
114
+ function readToolDefinitions(maxItems) {
115
+ return [
116
+ toolDefinition(
117
+ 'schema_list_collections',
118
+ 'List project collections the current YunCMS user is allowed to read. Use this before guessing collection names.',
119
+ { type: 'object', properties: {}, required: [] },
120
+ ),
121
+ toolDefinition(
122
+ 'schema_describe_collection',
123
+ 'Describe readable fields, direct relations and CRUD capabilities for one collection using the current user permissions.',
124
+ {
125
+ type: 'object',
126
+ properties: { collection: { type: 'string' } },
127
+ required: ['collection'],
128
+ },
129
+ ),
130
+ toolDefinition(
131
+ 'items_read_many',
132
+ 'Read collection rows with YunCMS filtering, search, sorting, aggregates and relation-aware fields. Normal RBAC and query limits always apply.',
133
+ {
134
+ type: 'object',
135
+ properties: {
136
+ collection: { type: 'string' },
137
+ fields: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
138
+ expand: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
139
+ filter: { type: 'object' },
140
+ search: { type: 'string', maxLength: 200 },
141
+ sort: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
142
+ aggregate: { type: 'object' },
143
+ groupBy: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
144
+ limit: { type: 'integer', minimum: 1, maximum: maxItems },
145
+ offset: { type: 'integer', minimum: 0, maximum: 1_000_000 },
146
+ },
147
+ required: ['collection'],
148
+ },
149
+ ),
150
+ toolDefinition(
151
+ 'items_read_one',
152
+ 'Read one item by id with optional field and relation selection. Normal YunCMS read permissions apply.',
153
+ {
154
+ type: 'object',
155
+ properties: {
156
+ collection: { type: 'string' },
157
+ id: { oneOf: [{ type: 'string' }, { type: 'number' }] },
158
+ fields: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
159
+ expand: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, maxItems: 100 }] },
160
+ },
161
+ required: ['collection', 'id'],
162
+ },
163
+ ),
164
+ ];
165
+ }
166
+
167
+ function writeToolDefinitions({ deletesEnabled = false } = {}) {
168
+ const tools = [
169
+ toolDefinition(
170
+ 'items_create',
171
+ 'Create one YunCMS item. Use only when the user clearly asks to create data. Field permissions, validation, hooks and auditing apply.',
172
+ {
173
+ type: 'object',
174
+ properties: { collection: { type: 'string' }, data: { type: 'object' } },
175
+ required: ['collection', 'data'],
176
+ },
177
+ ),
178
+ toolDefinition(
179
+ 'items_update',
180
+ 'Update one YunCMS item by id. Use only when the user clearly asks to change data. Normal permissions and validation apply.',
181
+ {
182
+ type: 'object',
183
+ properties: {
184
+ collection: { type: 'string' },
185
+ id: { oneOf: [{ type: 'string' }, { type: 'number' }] },
186
+ data: { type: 'object' },
187
+ },
188
+ required: ['collection', 'id', 'data'],
189
+ },
190
+ ),
191
+ ];
192
+ if (deletesEnabled) tools.push(toolDefinition(
193
+ 'items_delete',
194
+ 'Delete one YunCMS item by id. Use only when the user explicitly asks to delete that item. Normal delete permissions and hooks apply.',
195
+ {
196
+ type: 'object',
197
+ properties: {
198
+ collection: { type: 'string' },
199
+ id: { oneOf: [{ type: 'string' }, { type: 'number' }] },
200
+ },
201
+ required: ['collection', 'id'],
202
+ },
203
+ ));
204
+ return tools;
205
+ }
206
+
207
+ export function aiToolDefinitions({
208
+ writesEnabled = false,
209
+ deletesEnabled = false,
210
+ maxItems = 100,
211
+ } = {}) {
212
+ return writesEnabled
213
+ ? [...readToolDefinitions(maxItems), ...writeToolDefinitions({ deletesEnabled })]
214
+ : readToolDefinitions(maxItems);
215
+ }
216
+
217
+ export async function executeAiTool(req, name, args, {
218
+ writesEnabled = false,
219
+ deletesEnabled = false,
220
+ maxItems = 100,
221
+ } = {}) {
222
+ if (name === 'schema_list_collections') {
223
+ parseArguments(z.object({}).strict(), args);
224
+ return { collections: await listReadableCollections(req, { maxItems }) };
225
+ }
226
+ if (name === 'schema_describe_collection') {
227
+ const input = parseArguments(describeSchema, args);
228
+ return describeReadableCollection(req, input.collection);
229
+ }
230
+ if (name === 'items_read_many') {
231
+ const { collection, ...query } = parseArguments(readManySchema(maxItems), args);
232
+ return readManyWithRelations({
233
+ collection,
234
+ query: { ...query, limit: query.limit ?? Math.min(100, maxItems) },
235
+ options: serviceOptionsFromRequest(req),
236
+ ItemsServiceClass: req.context.services.ItemsService,
237
+ });
238
+ }
239
+ if (name === 'items_read_one') {
240
+ const input = parseArguments(readOneSchema, args);
241
+ return readOneWithRelations({
242
+ collection: input.collection,
243
+ id: input.id,
244
+ query: {
245
+ ...(input.fields ? { fields: input.fields } : {}),
246
+ ...(input.expand ? { expand: input.expand } : {}),
247
+ },
248
+ options: serviceOptionsFromRequest(req),
249
+ ItemsServiceClass: req.context.services.ItemsService,
250
+ });
251
+ }
252
+
253
+ if (!writesEnabled) throw toolError('AI_TOOL_FORBIDDEN', 'Data-changing assistant tools are disabled');
254
+
255
+ if (name === 'items_create') {
256
+ const input = parseArguments(createSchema, args);
257
+ return itemService(req, input.collection).createOne(input.data);
258
+ }
259
+ if (name === 'items_update') {
260
+ const input = parseArguments(updateSchema, args);
261
+ return itemService(req, input.collection).updateOne(input.id, input.data);
262
+ }
263
+ if (name === 'items_delete') {
264
+ if (!deletesEnabled) throw toolError('AI_TOOL_FORBIDDEN', 'Assistant delete tools are disabled');
265
+ const input = parseArguments(deleteSchema, args);
266
+ return { deleted: await itemService(req, input.collection).deleteOne(input.id) };
267
+ }
268
+
269
+ throw toolError('AI_TOOL_UNKNOWN', 'The assistant requested an unknown tool');
270
+ }
271
+
272
+ export function safeAiToolError(error) {
273
+ const code = SAFE_TOOL_ERROR_CODES.has(error?.code)
274
+ || ['AI_TOOL_ARGUMENTS_INVALID', 'AI_TOOL_FORBIDDEN', 'AI_TOOL_UNKNOWN'].includes(error?.code)
275
+ ? error.code
276
+ : 'INTERNAL_ERROR';
277
+ return {
278
+ error: {
279
+ code,
280
+ message: code === 'INTERNAL_ERROR'
281
+ ? 'Tool execution failed'
282
+ : String(error?.message ?? 'Tool execution failed'),
283
+ },
284
+ };
285
+ }
286
+
287
+ export function serializeAiToolResult(value, maxBytes = 250_000) {
288
+ const text = JSON.stringify({ data: value });
289
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text;
290
+ return JSON.stringify({
291
+ error: {
292
+ code: 'AI_TOOL_RESULT_TOO_LARGE',
293
+ message: `Tool result exceeds the configured ${maxBytes} byte limit. Narrow the query and try again.`,
294
+ },
295
+ });
296
+ }
297
+
298
+ export function operationSummary(name, args = {}, success = true) {
299
+ return {
300
+ operation: name,
301
+ collection: typeof args.collection === 'string' ? args.collection : null,
302
+ success,
303
+ };
304
+ }
package/src/app.js CHANGED
@@ -16,6 +16,7 @@ import { createItemsRouter } from './routes/items.js';
16
16
  import { createPermissionsRouter } from './routes/permissions.js';
17
17
  import { createRolesRouter } from './routes/roles.js';
18
18
  import { createSchemaRouter } from './routes/schema.js';
19
+ import { createStudioNavigationRouter } from './routes/studio-navigation.js';
19
20
  import { createStudioSettingsRouter } from './routes/studio-settings.js';
20
21
  import { createSystemSchemaRouter } from './routes/system-schema.js';
21
22
  import { createUsersRouter } from './routes/users.js';
@@ -127,6 +128,7 @@ export function createApp({
127
128
  redisClient = null,
128
129
  externalAuthRegistry = null,
129
130
  endpointExtensions = [],
131
+ aiRouter = null,
130
132
  mcpRouter = null,
131
133
  studioRoot = undefined,
132
134
  }) {
@@ -187,7 +189,9 @@ export function createApp({
187
189
  const requestEvents = createRequestEvents(emitter);
188
190
  if (requestEvents) app.use(requestEvents);
189
191
 
192
+ if (aiRouter) app.use('/ai', aiRouter);
190
193
  if (mcpRouter) app.use('/mcp', mcpRouter);
194
+ app.use('/studio-navigation', createStudioNavigationRouter());
191
195
  app.use('/studio-settings', createStudioSettingsRouter());
192
196
  app.use('/auth', createAuthRouter({ mailer, config, logger, rateLimitStore, externalAuthRegistry }));
193
197
  app.use('/items', createItemsRouter());
@@ -15,6 +15,7 @@ const STATUS_BY_CODE = new Map([
15
15
  ['ROLE_NOT_FOUND', 404],
16
16
  ['PERMISSION_NOT_FOUND', 404],
17
17
  ['FILE_NOT_FOUND', 404],
18
+ ['NAVIGATION_GROUP_NOT_FOUND', 404],
18
19
  ['NOT_FOUND', 404],
19
20
  ['COLLECTION_EXISTS', 409],
20
21
  ['FIELD_EXISTS', 409],
@@ -28,6 +29,9 @@ const STATUS_BY_CODE = new Map([
28
29
  ['DUPLICATE_KEY', 409],
29
30
  ['FOREIGN_KEY_MISSING', 409],
30
31
  ['FOREIGN_KEY_RESTRICTED', 409],
32
+ ['SINGLETON_ITEM_EXISTS', 409],
33
+ ['SINGLETON_MULTIPLE_ITEMS', 409],
34
+ ['SINGLETON_BULK_CREATE_FORBIDDEN', 409],
31
35
  ['RATE_LIMITED', 429],
32
36
  ['PAYLOAD_TOO_LARGE', 413],
33
37
  ['INVALID_QUERY', 400],
@@ -43,6 +47,9 @@ const STATUS_BY_CODE = new Map([
43
47
  ['INVALID_FILE_CONTENT', 400],
44
48
  ['FILE_MIME_MISMATCH', 400],
45
49
  ['INVALID_MAIL_MESSAGE', 400],
50
+ ['INVALID_AI_REQUEST', 400],
51
+ ['INVALID_AI_CONFIG', 400],
52
+ ['AI_TOOL_ARGUMENTS_INVALID', 400],
46
53
  ['STORAGE_NOT_FOUND', 400],
47
54
  ['STORAGE_INVENTORY_UNSUPPORTED', 400],
48
55
  ['UNSUPPORTED_SCHEMA_UPDATE', 400],
@@ -58,6 +65,14 @@ const STATUS_BY_CODE = new Map([
58
65
  ['FILTER_REQUIRED', 400],
59
66
  ['VALIDATION_FAILED', 400],
60
67
  ['VALIDATION_BULK_LIMIT', 400],
68
+ ['AI_PROVIDER_UNAVAILABLE', 502],
69
+ ['AI_PROVIDER_RESPONSE_INVALID', 502],
70
+ ['AI_TOOL_ROUND_LIMIT', 502],
71
+ ['AI_TOOL_CALL_LIMIT', 502],
72
+ ['AI_PROVIDER_TIMEOUT', 504],
73
+ ['AI_NOT_CONFIGURED', 503],
74
+ ['AI_SETTINGS_KEY_INVALID', 503],
75
+ ['AI_SECRET_DECRYPT_FAILED', 503],
61
76
  ['MAIL_NOT_CONFIGURED', 503],
62
77
  ['STORAGE_INVENTORY_FAILED', 503],
63
78
  ['DATABASE_MIGRATION_REQUIRED', 503],
@@ -0,0 +1,80 @@
1
+ import express from 'express';
2
+
3
+ function authError() {
4
+ const error = new Error('Yapay Zeka requires an authenticated YunCMS account');
5
+ error.code = 'UNAUTHORIZED';
6
+ return error;
7
+ }
8
+
9
+ function requireAuthenticated(req) {
10
+ if (req?.authMethod === 'public' || !req?.accountability?.user) throw authError();
11
+ }
12
+
13
+ function requireAdministrator(req) {
14
+ requireAuthenticated(req);
15
+ if (req.accountability?.admin === true || req.accountability?.system === true) return;
16
+ const error = new Error('Yapay Zeka settings require administrator access');
17
+ error.code = 'FORBIDDEN';
18
+ throw error;
19
+ }
20
+
21
+ export function aiRequestAccess(body = {}) {
22
+ const allowWrites = body?.allow_writes === true;
23
+ return {
24
+ allowWrites,
25
+ allowDeletes: allowWrites && body?.allow_deletes === true,
26
+ };
27
+ }
28
+
29
+ export function createAiRouter({ assistant, settingsStore } = {}) {
30
+ if (!assistant) throw new Error('AI assistant service is required');
31
+ if (!settingsStore) throw new Error('AI settings store is required');
32
+ const router = express.Router();
33
+
34
+ router.get('/status', async (req, res, next) => {
35
+ try {
36
+ requireAuthenticated(req);
37
+ return res.json({ data: await assistant.status(), request_id: req.id });
38
+ } catch (error) {
39
+ return next(error);
40
+ }
41
+ });
42
+
43
+ router.get('/settings', async (req, res, next) => {
44
+ try {
45
+ requireAdministrator(req);
46
+ return res.json({ data: await settingsStore.readAdmin(), request_id: req.id });
47
+ } catch (error) {
48
+ return next(error);
49
+ }
50
+ });
51
+
52
+ router.patch('/settings', async (req, res, next) => {
53
+ try {
54
+ requireAdministrator(req);
55
+ const data = await settingsStore.update(req.body ?? {});
56
+ return res.json({ data, request_id: req.id });
57
+ } catch (error) {
58
+ return next(error);
59
+ }
60
+ });
61
+
62
+ router.post('/chat', async (req, res, next) => {
63
+ try {
64
+ requireAuthenticated(req);
65
+ const access = aiRequestAccess(req.body);
66
+ const result = await assistant.chat(req, {
67
+ messages: req.body?.messages,
68
+ locale: req.body?.locale,
69
+ ...access,
70
+ });
71
+ return res.json({ data: result, request_id: req.id });
72
+ } catch (error) {
73
+ return next(error);
74
+ }
75
+ });
76
+
77
+ return router;
78
+ }
79
+
80
+ export { requireAdministrator, requireAuthenticated };
@@ -0,0 +1,59 @@
1
+ import express from 'express';
2
+
3
+ import { serviceOptionsFromRequest } from '../service-options.js';
4
+
5
+ function navigationService(req) {
6
+ const Service = req.context.services.NavigationGroupsService;
7
+ return new Service(serviceOptionsFromRequest(req));
8
+ }
9
+
10
+ function requireStudioUser(req) {
11
+ if (req?.authMethod !== 'public' && req?.accountability?.user) return;
12
+ const error = new Error('Studio navigation requires an authenticated YunCMS account');
13
+ error.code = 'UNAUTHORIZED';
14
+ throw error;
15
+ }
16
+
17
+ export function createStudioNavigationRouter() {
18
+ const router = express.Router();
19
+
20
+ router.get('/groups', async (req, res, next) => {
21
+ try {
22
+ requireStudioUser(req);
23
+ return res.json({ data: await navigationService(req).readMany(), request_id: req.id });
24
+ } catch (error) {
25
+ return next(error);
26
+ }
27
+ });
28
+
29
+ router.post('/groups', async (req, res, next) => {
30
+ try {
31
+ requireStudioUser(req);
32
+ return res.status(201).json({ data: await navigationService(req).createOne(req.body ?? {}), request_id: req.id });
33
+ } catch (error) {
34
+ return next(error);
35
+ }
36
+ });
37
+
38
+ router.patch('/groups/:id', async (req, res, next) => {
39
+ try {
40
+ requireStudioUser(req);
41
+ return res.json({ data: await navigationService(req).updateOne(req.params.id, req.body ?? {}), request_id: req.id });
42
+ } catch (error) {
43
+ return next(error);
44
+ }
45
+ });
46
+
47
+ router.delete('/groups/:id', async (req, res, next) => {
48
+ try {
49
+ requireStudioUser(req);
50
+ return res.json({ data: await navigationService(req).deleteOne(req.params.id), request_id: req.id });
51
+ } catch (error) {
52
+ return next(error);
53
+ }
54
+ });
55
+
56
+ return router;
57
+ }
58
+
59
+ export { requireStudioUser };
package/src/server.js CHANGED
@@ -20,11 +20,15 @@ import {
20
20
  SmtpMailer,
21
21
  } from '@yunsoft/yuncms-core';
22
22
  import { createApp } from './app.js';
23
+ import { AiAssistantService } from './ai/service.js';
24
+ import { loadOrCreateAiSettingsKey } from './ai/secret-key.js';
25
+ import { AiSettingsStore } from './ai/settings-store.js';
23
26
  import { INTERNAL_AUDIT_EVENTS } from './audit-events.js';
24
27
  import { loadExternalAuthConfig } from './external-auth/config.js';
25
28
  import { ExternalAuthProviderRegistry } from './external-auth/providers.js';
26
29
  import { loadExtensionRuntime } from './extensions/runtime.js';
27
30
  import { createMcpRouter } from './mcp.js';
31
+ import { createAiRouter } from './routes/ai.js';
28
32
 
29
33
  loadEnvFileIfPresent();
30
34
  await assertMaintenanceStartupAllowed({ cwd: process.cwd(), env: process.env });
@@ -103,6 +107,10 @@ function registerInternalAudit({ emitter, services }) {
103
107
 
104
108
  async function start() {
105
109
  await assertDatabaseCompatible(pool);
110
+ const aiKey = await loadOrCreateAiSettingsKey();
111
+ const aiSettingsStore = new AiSettingsStore({ database: pool, key: aiKey.key });
112
+ if (aiKey.created) logger.info('YunCMS AI settings encryption key created');
113
+
106
114
  if (redisClient) {
107
115
  try {
108
116
  await redisClient.connect();
@@ -124,6 +132,8 @@ async function start() {
124
132
  database: pool,
125
133
  logger,
126
134
  });
135
+ const aiAssistant = new AiAssistantService({ settingsStore: aiSettingsStore, logger });
136
+ const aiRouter = createAiRouter({ assistant: aiAssistant, settingsStore: aiSettingsStore });
127
137
  const mcpRouter = createMcpRouter({ config, logger });
128
138
  mailer?.setEmitter(emitter);
129
139
  registerInternalAudit({ emitter, services });
@@ -152,6 +162,7 @@ async function start() {
152
162
  redisClient,
153
163
  externalAuthRegistry,
154
164
  endpointExtensions: extensionRuntime.endpointExtensions,
165
+ aiRouter,
155
166
  mcpRouter,
156
167
  });
157
168
 
@@ -208,4 +219,4 @@ start().catch(async (error) => {
208
219
  await redisClient?.close().catch(() => {});
209
220
  await closeDatabasePool(pool).catch(() => {});
210
221
  process.exit(1);
211
- });
222
+ });