webspresso 0.0.91 → 0.0.93
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 +103 -2864
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +8 -2
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +2 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +17 -5
- package/core/compression/compression-stream.js +1 -1
- package/core/compression/middleware.js +9 -0
- package/core/compression/negotiate.js +23 -11
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/index.js +38 -2
- package/core/orm/model.js +11 -2
- package/core/queue/adapters/database.js +46 -13
- package/core/queue/adapters/memory.js +21 -0
- package/core/queue/adapters/redis.js +21 -0
- package/core/queue/manager.js +46 -20
- package/core/validation/index.js +70 -0
- package/index.d.ts +99 -5
- package/index.js +7 -1
- package/package.json +9 -1
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
- package/plugins/admin-panel/index.js +65 -6
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/data-exchange/export-xlsx.js +28 -6
- package/plugins/data-exchange/import.js +45 -20
- package/plugins/data-exchange/index.js +5 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/index.js +4 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +139 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/src/file-router.js +184 -123
- package/src/njk-frontmatter.js +24 -14
- package/src/server.js +30 -12
- package/src/services/discovery.js +2 -1
- package/src/services/registry.js +2 -0
- package/src/services/validator.js +2 -53
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
|
@@ -396,10 +396,15 @@ function getFieldRenderer(col, modelMeta) {
|
|
|
396
396
|
const custom = window.__customFieldRenderers && window.__customFieldRenderers[col.customField.type];
|
|
397
397
|
if (custom) {
|
|
398
398
|
return (col, value, onChange, readonly, error) => {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
399
|
+
try {
|
|
400
|
+
if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
|
|
401
|
+
return m(custom.edit, { col, value, onChange, readonly, error });
|
|
402
|
+
} else if (typeof custom.edit === 'function') {
|
|
403
|
+
return custom.edit(value, onChange, col, readonly, error);
|
|
404
|
+
}
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.error('[Admin Panel] Error rendering custom field edit for "' + col.customField.type + '":', err);
|
|
407
|
+
return m('div.text-red-500.text-xs', 'Edit render error: ' + (err.message || String(err)));
|
|
403
408
|
}
|
|
404
409
|
};
|
|
405
410
|
}
|
|
@@ -409,10 +414,15 @@ function getFieldRenderer(col, modelMeta) {
|
|
|
409
414
|
if (col.type && window.__customFieldRenderers && window.__customFieldRenderers[col.type]) {
|
|
410
415
|
const custom = window.__customFieldRenderers[col.type];
|
|
411
416
|
return (col, value, onChange, readonly, error) => {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
417
|
+
try {
|
|
418
|
+
if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
|
|
419
|
+
return m(custom.edit, { col, value, onChange, readonly, error });
|
|
420
|
+
} else if (typeof custom.edit === 'function') {
|
|
421
|
+
return custom.edit(value, onChange, col, readonly, error);
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
console.error('[Admin Panel] Error rendering custom field edit for "' + col.type + '":', err);
|
|
425
|
+
return m('div.text-red-500.text-xs', 'Edit render error: ' + (err.message || String(err)));
|
|
416
426
|
}
|
|
417
427
|
};
|
|
418
428
|
}
|
|
@@ -73,10 +73,15 @@ function formatCellValue(value, col, record = null) {
|
|
|
73
73
|
const customRendererType = (col && col.customField && col.customField.type) || (col && col.type);
|
|
74
74
|
const custom = window.__customFieldRenderers && window.__customFieldRenderers[customRendererType];
|
|
75
75
|
if (custom && custom.display) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
try {
|
|
77
|
+
if (typeof custom.display === 'object' && custom.display.view) {
|
|
78
|
+
return m(custom.display, { value, col, record });
|
|
79
|
+
} else if (typeof custom.display === 'function') {
|
|
80
|
+
return custom.display(value, record);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('[Admin Panel] Error rendering custom field display for "' + customRendererType + '":', err);
|
|
84
|
+
return String(value);
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -325,10 +325,15 @@ const RecordList = {
|
|
|
325
325
|
const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
|
|
326
326
|
const url = URL.createObjectURL(blob);
|
|
327
327
|
const a = document.createElement('a');
|
|
328
|
+
a.style.display = 'none';
|
|
328
329
|
a.href = url;
|
|
329
330
|
a.download = modelName + '-export.json';
|
|
331
|
+
document.body.appendChild(a);
|
|
330
332
|
a.click();
|
|
331
|
-
|
|
333
|
+
setTimeout(function () {
|
|
334
|
+
if (a.parentNode) a.parentNode.removeChild(a);
|
|
335
|
+
URL.revokeObjectURL(url);
|
|
336
|
+
}, 1000);
|
|
332
337
|
} catch (err) {
|
|
333
338
|
alert('Error: ' + err.message);
|
|
334
339
|
} finally {
|
|
@@ -351,15 +356,24 @@ const RecordList = {
|
|
|
351
356
|
const payload = state.selectAllMode
|
|
352
357
|
? { selectAll: true, filters: state.filters }
|
|
353
358
|
: { ids: Array.from(state.selectedRecords) };
|
|
359
|
+
if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
|
|
360
|
+
alert('Please select at least one record to export');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
354
363
|
const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', payload);
|
|
355
364
|
// Download as file
|
|
356
365
|
const blob = new Blob([response.data], { type: 'text/csv' });
|
|
357
366
|
const url = URL.createObjectURL(blob);
|
|
358
367
|
const a = document.createElement('a');
|
|
368
|
+
a.style.display = 'none';
|
|
359
369
|
a.href = url;
|
|
360
370
|
a.download = modelName + '-export.csv';
|
|
371
|
+
document.body.appendChild(a);
|
|
361
372
|
a.click();
|
|
362
|
-
|
|
373
|
+
setTimeout(function () {
|
|
374
|
+
if (a.parentNode) a.parentNode.removeChild(a);
|
|
375
|
+
URL.revokeObjectURL(url);
|
|
376
|
+
}, 1000);
|
|
363
377
|
} catch (err) {
|
|
364
378
|
alert('Error: ' + err.message);
|
|
365
379
|
} finally {
|
|
@@ -382,6 +396,10 @@ const RecordList = {
|
|
|
382
396
|
const payload = state.selectAllMode
|
|
383
397
|
? { selectAll: true, filters: state.filters }
|
|
384
398
|
: { ids: Array.from(state.selectedRecords) };
|
|
399
|
+
if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
|
|
400
|
+
alert('Please select at least one record to export');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
385
403
|
if (state.trashedView) payload.trashed = 'only';
|
|
386
404
|
await downloadDataExchangeXlsx(modelName, payload);
|
|
387
405
|
} catch (err) {
|
|
@@ -51,6 +51,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
51
51
|
configure,
|
|
52
52
|
uploadUrl: uploadUrlOption,
|
|
53
53
|
richTextSanitize = true,
|
|
54
|
+
dataExchange: dataExchangeOption = true,
|
|
54
55
|
} = options;
|
|
55
56
|
|
|
56
57
|
const adminUsersConfig = typeof adminUsersOption === 'object' ? adminUsersOption : { enabled: adminUsersOption !== false };
|
|
@@ -190,6 +191,17 @@ function adminPanelPlugin(options = {}) {
|
|
|
190
191
|
}));
|
|
191
192
|
ctx.app._webspressoSessionInitialized = true;
|
|
192
193
|
}
|
|
194
|
+
|
|
195
|
+
// Auto-register built-in exchange services if enabled and serviceRegistry exists
|
|
196
|
+
if (enabled && dataExchangeOption !== false && ctx.app && ctx.app.serviceRegistry) {
|
|
197
|
+
const { createExchangeServices } = require('../../src/services/builtins/exchange');
|
|
198
|
+
const exchangeServices = createExchangeServices({ db });
|
|
199
|
+
for (const [name, def] of Object.entries(exchangeServices)) {
|
|
200
|
+
if (!ctx.app.serviceRegistry.has(name)) {
|
|
201
|
+
ctx.app.serviceRegistry.register(name, def);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
193
205
|
},
|
|
194
206
|
|
|
195
207
|
/**
|
|
@@ -268,7 +280,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
268
280
|
path: adminPath,
|
|
269
281
|
db,
|
|
270
282
|
AdminUser,
|
|
271
|
-
hashPassword: (password, rounds) => bcrypt.hash(password, rounds),
|
|
283
|
+
hashPassword: (password, rounds) => bcrypt.hash(password, process.env.NODE_ENV === 'test' ? 4 : (rounds || 10)),
|
|
272
284
|
comparePassword: (password, hash) => bcrypt.compare(password, hash),
|
|
273
285
|
richTextSanitize,
|
|
274
286
|
});
|
|
@@ -287,7 +299,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
287
299
|
adminUsersHandlers = createAdminUsersApiHandlers({
|
|
288
300
|
db,
|
|
289
301
|
AdminUser,
|
|
290
|
-
hashPassword: (password, rounds) => bcrypt.hash(password, rounds),
|
|
302
|
+
hashPassword: (password, rounds) => bcrypt.hash(password, process.env.NODE_ENV === 'test' ? 4 : (rounds || 10)),
|
|
291
303
|
});
|
|
292
304
|
}
|
|
293
305
|
|
|
@@ -379,6 +391,49 @@ function adminPanelPlugin(options = {}) {
|
|
|
379
391
|
// Query API routes (auth required)
|
|
380
392
|
ctx.addRoute('get', `${adminPath}/api/models/:model/queries/:query`, requireAuth, apiHandlers.queryHandler);
|
|
381
393
|
|
|
394
|
+
// Data exchange (Excel export & CSV/XLSX import) routes
|
|
395
|
+
const dataExchangeConfig = typeof dataExchangeOption === 'object' ? dataExchangeOption : { enabled: dataExchangeOption !== false };
|
|
396
|
+
if (dataExchangeConfig.enabled !== false && !ctx.app?._dataExchangeRoutesRegistered) {
|
|
397
|
+
const { createExportXlsxHandler } = require('../data-exchange/export-xlsx');
|
|
398
|
+
const { createImportHandler } = require('../data-exchange/import');
|
|
399
|
+
const exportHandler = createExportXlsxHandler({ db, maxRows: dataExchangeConfig.maxRows || 10000 });
|
|
400
|
+
const importHandler = createImportHandler({
|
|
401
|
+
db,
|
|
402
|
+
maxRows: dataExchangeConfig.maxRows || 10000,
|
|
403
|
+
maxFileBytes: dataExchangeConfig.maxFileBytes || 10 * 1024 * 1024,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const base = `${adminPath}/api/data-exchange`;
|
|
407
|
+
ctx.addRoute('get', `${base}/export/:model`, requireAuth, exportHandler);
|
|
408
|
+
ctx.addRoute('post', `${base}/export/:model`, requireAuth, exportHandler);
|
|
409
|
+
ctx.addRoute('post', `${base}/import/:model`, requireAuth, importHandler);
|
|
410
|
+
|
|
411
|
+
if (!registry.bulkActions.has('export-xlsx')) {
|
|
412
|
+
registry.registerBulkAction('export-xlsx', {
|
|
413
|
+
label: 'Export as Excel',
|
|
414
|
+
icon: 'download',
|
|
415
|
+
color: 'blue',
|
|
416
|
+
models: '*',
|
|
417
|
+
confirm: false,
|
|
418
|
+
handler: async (records, modelName, { db: d }) => {
|
|
419
|
+
const { getModel } = require('../../core/orm/model');
|
|
420
|
+
const model = d.getModel ? d.getModel(modelName) : getModel(modelName);
|
|
421
|
+
const pk = model?.primaryKey || 'id';
|
|
422
|
+
const ids = records.map((r) => r[pk]).join(',');
|
|
423
|
+
return {
|
|
424
|
+
download: true,
|
|
425
|
+
url: `${adminPath}/api/data-exchange/export/${modelName}?ids=${encodeURIComponent(ids)}`,
|
|
426
|
+
filename: `${modelName}_export.xlsx`,
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (ctx.app) {
|
|
433
|
+
ctx.app._dataExchangeRoutesRegistered = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
382
437
|
// ==========================================
|
|
383
438
|
// Static Vendor Assets (Offline Zero-CDN)
|
|
384
439
|
// ==========================================
|
|
@@ -476,10 +531,14 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
476
531
|
|
|
477
532
|
const settings = registry.settings;
|
|
478
533
|
const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
|
|
479
|
-
return `
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
534
|
+
return `try {
|
|
535
|
+
window.__customFieldRenderers['${type}'] = {
|
|
536
|
+
display: ${r.display ? serializeJs(r.display) : 'null'},
|
|
537
|
+
edit: ${r.edit ? serializeJs(r.edit) : 'null'}
|
|
538
|
+
};
|
|
539
|
+
} catch (err) {
|
|
540
|
+
console.error('[Admin Panel] Failed to register custom field renderer "${type}":', err);
|
|
541
|
+
}`;
|
|
483
542
|
}).join('\n');
|
|
484
543
|
|
|
485
544
|
return `<!DOCTYPE html>
|
|
@@ -238,11 +238,18 @@ async function executeBulkAction(action, selectedIds, modelName) {
|
|
|
238
238
|
if (result.result?.download) {
|
|
239
239
|
// Trigger download
|
|
240
240
|
const link = document.createElement('a');
|
|
241
|
-
link.
|
|
241
|
+
link.style.display = 'none';
|
|
242
|
+
const rawUrl = result.result.url || '';
|
|
243
|
+
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
244
|
+
link.href = (rawUrl.startsWith('/') && !rawUrl.startsWith(adminPath))
|
|
245
|
+
? adminPath + rawUrl
|
|
246
|
+
: rawUrl;
|
|
242
247
|
link.download = result.result.filename || 'export';
|
|
243
248
|
document.body.appendChild(link);
|
|
244
249
|
link.click();
|
|
245
|
-
|
|
250
|
+
setTimeout(function () {
|
|
251
|
+
if (link.parentNode) link.parentNode.removeChild(link);
|
|
252
|
+
}, 1000);
|
|
246
253
|
return { success: true, message: 'Download started' };
|
|
247
254
|
}
|
|
248
255
|
|
|
@@ -384,7 +384,14 @@ const Widget = {
|
|
|
384
384
|
? m('div.flex.justify-center.py-8', m(Spinner))
|
|
385
385
|
: error
|
|
386
386
|
? m('div.text-red-500.text-sm', error)
|
|
387
|
-
: (
|
|
387
|
+
: (() => {
|
|
388
|
+
try {
|
|
389
|
+
return (WidgetRenderers[widget.id] || WidgetRenderers.default).render(data);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error('[Admin Panel] Error rendering widget "' + widget.id + '":', err);
|
|
392
|
+
return m('div.text-red-500.text-xs', 'Widget render error: ' + (err.message || String(err)));
|
|
393
|
+
}
|
|
394
|
+
})(),
|
|
388
395
|
]),
|
|
389
396
|
]);
|
|
390
397
|
},
|
|
@@ -7,6 +7,32 @@ const ExcelJS = require('exceljs');
|
|
|
7
7
|
const { sanitizeForOutput } = require('../../core/orm/utils');
|
|
8
8
|
const { resolveExportRecords } = require('./record-selection');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
12
|
+
* @param {object[]} records
|
|
13
|
+
* @returns {Promise<Buffer>}
|
|
14
|
+
*/
|
|
15
|
+
function getModelExportColumns(model, cleanRecords = []) {
|
|
16
|
+
const hiddenSet = new Set(model.hidden || []);
|
|
17
|
+
const colSet = new Set();
|
|
18
|
+
if (model.columns && typeof model.columns.keys === 'function') {
|
|
19
|
+
for (const c of model.columns.keys()) {
|
|
20
|
+
if (!hiddenSet.has(c)) colSet.add(c);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (model.schema?.shape) {
|
|
24
|
+
for (const c of Object.keys(model.schema.shape)) {
|
|
25
|
+
if (!hiddenSet.has(c)) colSet.add(c);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (colSet.size === 0 && cleanRecords.length > 0) {
|
|
29
|
+
for (const c of Object.keys(cleanRecords[0])) {
|
|
30
|
+
if (!hiddenSet.has(c)) colSet.add(c);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return Array.from(colSet);
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
/**
|
|
11
37
|
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
12
38
|
* @param {object[]} records
|
|
@@ -14,12 +40,7 @@ const { resolveExportRecords } = require('./record-selection');
|
|
|
14
40
|
*/
|
|
15
41
|
async function buildXlsxBuffer(model, records) {
|
|
16
42
|
const clean = sanitizeForOutput(records, model);
|
|
17
|
-
const
|
|
18
|
-
const columns = (model.columns && model.columns.size > 0)
|
|
19
|
-
? Array.from(model.columns.keys()).filter((c) => !hiddenSet.has(c))
|
|
20
|
-
: (model.schema?.shape
|
|
21
|
-
? Object.keys(model.schema.shape).filter((c) => !hiddenSet.has(c))
|
|
22
|
-
: Object.keys(clean[0] || {}));
|
|
43
|
+
const columns = getModelExportColumns(model, clean);
|
|
23
44
|
|
|
24
45
|
const wb = new ExcelJS.Workbook();
|
|
25
46
|
const ws = wb.addWorksheet('Export', {
|
|
@@ -87,4 +108,5 @@ function createExportXlsxHandler(opts) {
|
|
|
87
108
|
module.exports = {
|
|
88
109
|
buildXlsxBuffer,
|
|
89
110
|
createExportXlsxHandler,
|
|
111
|
+
getModelExportColumns,
|
|
90
112
|
};
|
|
@@ -10,13 +10,18 @@ const { buildHeaderMapping, dataRowsToObjects } = require('./parse-table');
|
|
|
10
10
|
|
|
11
11
|
function allowedImportColumns(model) {
|
|
12
12
|
const hidden = new Set(model.hidden || []);
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const cols = new Set();
|
|
14
|
+
if (model.columns && typeof model.columns.keys === 'function') {
|
|
15
|
+
for (const c of model.columns.keys()) {
|
|
16
|
+
if (!hidden.has(c)) cols.add(c);
|
|
17
|
+
}
|
|
15
18
|
}
|
|
16
19
|
if (model.schema && model.schema.shape) {
|
|
17
|
-
|
|
20
|
+
for (const c of Object.keys(model.schema.shape)) {
|
|
21
|
+
if (!hidden.has(c)) cols.add(c);
|
|
22
|
+
}
|
|
18
23
|
}
|
|
19
|
-
return
|
|
24
|
+
return Array.from(cols);
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/**
|
|
@@ -35,12 +40,12 @@ function coerceCell(raw, meta, column) {
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
if (!meta || !meta.type) {
|
|
38
|
-
if (raw === 'true' || raw === '1' || raw === 'yes') return true;
|
|
39
|
-
if (raw === 'false' || raw === '0' || raw === 'no') return false;
|
|
40
43
|
if (typeof raw === 'string' && /^-?\d+(\.\d+)?$/.test(raw)) {
|
|
41
44
|
const num = Number(raw);
|
|
42
45
|
if (!Number.isNaN(num)) return num;
|
|
43
46
|
}
|
|
47
|
+
if (raw === 'true' || raw === 'yes') return true;
|
|
48
|
+
if (raw === 'false' || raw === 'no') return false;
|
|
44
49
|
return raw;
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -66,6 +71,14 @@ function coerceCell(raw, meta, column) {
|
|
|
66
71
|
if (s === 'false' || s === '0' || s === 'no') return false;
|
|
67
72
|
throw new Error(`Invalid boolean for ${column}`);
|
|
68
73
|
}
|
|
74
|
+
case 'date':
|
|
75
|
+
case 'datetime':
|
|
76
|
+
case 'timestamp': {
|
|
77
|
+
if (raw instanceof Date) return raw;
|
|
78
|
+
const d = new Date(raw);
|
|
79
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Invalid date for ${column}`);
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
69
82
|
case 'json': {
|
|
70
83
|
if (typeof raw === 'object') return raw;
|
|
71
84
|
try {
|
|
@@ -84,7 +97,7 @@ function buildPayloadForRow(model, data, mode) {
|
|
|
84
97
|
const payload = {};
|
|
85
98
|
for (const [col, raw] of Object.entries(data)) {
|
|
86
99
|
if (!allowed.has(col)) continue;
|
|
87
|
-
const meta = model.columns.get(col);
|
|
100
|
+
const meta = model.columns?.get ? model.columns.get(col) : undefined;
|
|
88
101
|
let v;
|
|
89
102
|
try {
|
|
90
103
|
v = coerceCell(raw, meta, col);
|
|
@@ -95,14 +108,14 @@ function buildPayloadForRow(model, data, mode) {
|
|
|
95
108
|
payload[col] = v;
|
|
96
109
|
}
|
|
97
110
|
|
|
98
|
-
const pk = model.primaryKey;
|
|
99
|
-
const pkMeta = model.columns.get(pk);
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
(payload[pk] === undefined || payload[pk] === null || payload[pk] === '')
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
const pk = model.primaryKey || 'id';
|
|
112
|
+
const pkMeta = model.columns?.get ? model.columns.get(pk) : undefined;
|
|
113
|
+
if (mode === 'insert') {
|
|
114
|
+
// In insert mode, remove auto-increment / auto-generated primary key
|
|
115
|
+
// so new records are created without unique constraint collision
|
|
116
|
+
if (pkMeta?.autoIncrement || pkMeta?.auto === 'create' || payload[pk] === undefined || payload[pk] === null || payload[pk] === '') {
|
|
117
|
+
delete payload[pk];
|
|
118
|
+
}
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
return payload;
|
|
@@ -139,15 +152,26 @@ async function parseXlsxToRows(buffer) {
|
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
function cellValueToPlain(cell) {
|
|
155
|
+
if (!cell) return '';
|
|
142
156
|
const v = cell.value;
|
|
143
157
|
if (v === null || v === undefined) return '';
|
|
144
158
|
if (v instanceof Date) return v.toISOString();
|
|
145
|
-
if (typeof v === 'object'
|
|
159
|
+
if (typeof v === 'object') {
|
|
160
|
+
if (v.error) return '';
|
|
146
161
|
if ('text' in v && typeof v.text === 'string') return v.text;
|
|
147
162
|
if ('richText' in v && Array.isArray(v.richText)) {
|
|
148
|
-
return v.richText.map((t) => t.text
|
|
163
|
+
return v.richText.map((t) => (t && t.text) ? t.text : '').join('');
|
|
164
|
+
}
|
|
165
|
+
if ('result' in v && v.result !== undefined) {
|
|
166
|
+
return cellValueToPlain({ value: v.result });
|
|
167
|
+
}
|
|
168
|
+
if ('hyperlink' in v) {
|
|
169
|
+
return v.text || v.hyperlink || '';
|
|
170
|
+
}
|
|
171
|
+
if (typeof v.toString === 'function' && v.toString !== Object.prototype.toString) {
|
|
172
|
+
return v.toString();
|
|
149
173
|
}
|
|
150
|
-
|
|
174
|
+
return '';
|
|
151
175
|
}
|
|
152
176
|
return v;
|
|
153
177
|
}
|
|
@@ -191,7 +215,8 @@ function createImportHandler(opts) {
|
|
|
191
215
|
let upsertKey = req.query.upsertKey || req.body?.upsertKey || model.primaryKey;
|
|
192
216
|
upsertKey = String(upsertKey);
|
|
193
217
|
|
|
194
|
-
|
|
218
|
+
const allowedCols = allowedImportColumns(model);
|
|
219
|
+
if (!allowedCols.includes(upsertKey)) {
|
|
195
220
|
return res.status(400).json({ error: `upsertKey "${upsertKey}" is not a column` });
|
|
196
221
|
}
|
|
197
222
|
if (model.hidden?.includes(upsertKey)) {
|
|
@@ -250,7 +275,7 @@ function createImportHandler(opts) {
|
|
|
250
275
|
continue;
|
|
251
276
|
}
|
|
252
277
|
|
|
253
|
-
const keyMeta = model.columns.get(upsertKey);
|
|
278
|
+
const keyMeta = model.columns?.get ? model.columns.get(upsertKey) : undefined;
|
|
254
279
|
let keyVal;
|
|
255
280
|
try {
|
|
256
281
|
keyVal = coerceCell(data[upsertKey], keyMeta, upsertKey);
|
|
@@ -49,6 +49,7 @@ function dataExchangePlugin(options = {}) {
|
|
|
49
49
|
|
|
50
50
|
onRoutesReady(ctx) {
|
|
51
51
|
if (!enabled) return;
|
|
52
|
+
if (ctx.app && ctx.app._dataExchangeRoutesRegistered) return;
|
|
52
53
|
|
|
53
54
|
const db = dbOption ?? ctx.db;
|
|
54
55
|
if (!db) {
|
|
@@ -87,6 +88,10 @@ function dataExchangePlugin(options = {}) {
|
|
|
87
88
|
},
|
|
88
89
|
});
|
|
89
90
|
}
|
|
91
|
+
|
|
92
|
+
if (ctx.app) {
|
|
93
|
+
ctx.app._dataExchangeRoutesRegistered = true;
|
|
94
|
+
}
|
|
90
95
|
},
|
|
91
96
|
};
|
|
92
97
|
}
|
|
@@ -15,18 +15,27 @@ function normalizeHeaderCell(h) {
|
|
|
15
15
|
* @returns {(string|null)[]} index → model column or null if unknown
|
|
16
16
|
*/
|
|
17
17
|
function buildHeaderMapping(headerRow, allowedKeys) {
|
|
18
|
-
const
|
|
18
|
+
const exactLowerToKey = new Map();
|
|
19
|
+
const strippedToKey = new Map();
|
|
19
20
|
for (const k of allowedKeys) {
|
|
20
|
-
|
|
21
|
+
const kStr = String(k);
|
|
22
|
+
exactLowerToKey.set(kStr.toLowerCase().replace(/\s+/g, '_'), k);
|
|
23
|
+
strippedToKey.set(kStr.toLowerCase().replace(/[_\s-]+/g, ''), k);
|
|
21
24
|
}
|
|
22
25
|
const mapping = [];
|
|
23
26
|
for (let i = 0; i < headerRow.length; i++) {
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
27
|
+
const raw = normalizeHeaderCell(headerRow[i]);
|
|
28
|
+
if (!raw) {
|
|
26
29
|
mapping.push(null);
|
|
27
30
|
continue;
|
|
28
31
|
}
|
|
29
|
-
|
|
32
|
+
const norm = raw.toLowerCase().replace(/\s+/g, '_');
|
|
33
|
+
let matched = exactLowerToKey.get(norm);
|
|
34
|
+
if (!matched) {
|
|
35
|
+
const stripped = raw.toLowerCase().replace(/[_\s-]+/g, '');
|
|
36
|
+
matched = strippedToKey.get(stripped);
|
|
37
|
+
}
|
|
38
|
+
mapping.push(matched ?? null);
|
|
30
39
|
}
|
|
31
40
|
return mapping;
|
|
32
41
|
}
|
package/plugins/index.js
CHANGED
|
@@ -39,6 +39,7 @@ const {
|
|
|
39
39
|
redis,
|
|
40
40
|
createRedisAdapter,
|
|
41
41
|
} = require('./realtime');
|
|
42
|
+
const { mcpPlugin, createMcpServer, McpServer } = require('./mcp');
|
|
42
43
|
|
|
43
44
|
module.exports = {
|
|
44
45
|
sitemapPlugin,
|
|
@@ -77,6 +78,9 @@ module.exports = {
|
|
|
77
78
|
redis,
|
|
78
79
|
createRedisAdapter,
|
|
79
80
|
queuePlugin: require('./queue'),
|
|
81
|
+
mcpPlugin,
|
|
82
|
+
createMcpServer,
|
|
83
|
+
McpServer,
|
|
80
84
|
};
|
|
81
85
|
|
|
82
86
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso MCP Authentication & Security
|
|
3
|
+
* Provides token validation and localhost IP restrictions for HTTP/SSE transports
|
|
4
|
+
* @module plugins/mcp/auth
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Constant-time string comparison to avoid timing attacks
|
|
11
|
+
* @param {string} a
|
|
12
|
+
* @param {string} b
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function safeEqual(a, b) {
|
|
16
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
17
|
+
const bufA = Buffer.from(a);
|
|
18
|
+
const bufB = Buffer.from(b);
|
|
19
|
+
if (bufA.length !== bufB.length) return false;
|
|
20
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if incoming request is from localhost
|
|
25
|
+
* @param {import('express').Request} req
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function isLocalhost(req) {
|
|
29
|
+
const ip = req.ip || req.connection?.remoteAddress || '';
|
|
30
|
+
return (
|
|
31
|
+
ip === '127.0.0.1' ||
|
|
32
|
+
ip === '::1' ||
|
|
33
|
+
ip === '::ffff:127.0.0.1' ||
|
|
34
|
+
ip.endsWith('127.0.0.1') ||
|
|
35
|
+
req.hostname === 'localhost'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates authentication middleware for MCP HTTP/SSE endpoints
|
|
41
|
+
* @param {Object} [options={}]
|
|
42
|
+
* @param {string} [options.token] - Pre-shared secret token
|
|
43
|
+
* @param {boolean} [options.localhostOnly=false] - Only allow localhost connections
|
|
44
|
+
* @param {function(import('express').Request): (boolean|Promise<boolean>)} [options.verify] - Custom verify function
|
|
45
|
+
* @returns {import('express').RequestHandler}
|
|
46
|
+
*/
|
|
47
|
+
function createMcpAuthMiddleware(options = {}) {
|
|
48
|
+
const { token, localhostOnly = false, verify } = options;
|
|
49
|
+
|
|
50
|
+
return async function mcpAuthMiddleware(req, res, next) {
|
|
51
|
+
// 1. Localhost check
|
|
52
|
+
if (localhostOnly && !isLocalhost(req)) {
|
|
53
|
+
return res.status(403).json({
|
|
54
|
+
jsonrpc: '2.0',
|
|
55
|
+
error: { code: -32000, message: 'Forbidden: MCP server only allows localhost connections' },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Custom verification
|
|
60
|
+
if (typeof verify === 'function') {
|
|
61
|
+
try {
|
|
62
|
+
const ok = await verify(req);
|
|
63
|
+
if (!ok) {
|
|
64
|
+
return res.status(401).json({
|
|
65
|
+
jsonrpc: '2.0',
|
|
66
|
+
error: { code: -32000, message: 'Unauthorized: Custom MCP verification failed' },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return next();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return res.status(401).json({
|
|
72
|
+
jsonrpc: '2.0',
|
|
73
|
+
error: { code: -32000, message: `Unauthorized: ${err.message}` },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 3. Pre-shared token check
|
|
79
|
+
if (token) {
|
|
80
|
+
let clientToken = '';
|
|
81
|
+
const authHeader = req.headers.authorization;
|
|
82
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
83
|
+
clientToken = authHeader.slice('Bearer '.length).trim();
|
|
84
|
+
} else if (req.query && req.query.token) {
|
|
85
|
+
clientToken = String(req.query.token).trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!clientToken || !safeEqual(clientToken, token)) {
|
|
89
|
+
return res.status(401).json({
|
|
90
|
+
jsonrpc: '2.0',
|
|
91
|
+
error: { code: -32000, message: 'Unauthorized: Invalid or missing MCP bearer token' },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
next();
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
createMcpAuthMiddleware,
|
|
102
|
+
isLocalhost,
|
|
103
|
+
safeEqual,
|
|
104
|
+
};
|