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.
Files changed (60) hide show
  1. package/README.md +103 -2864
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +8 -2
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +2 -0
  12. package/core/compileSchema.js +1 -5
  13. package/core/compression/compressible.js +17 -5
  14. package/core/compression/compression-stream.js +1 -1
  15. package/core/compression/middleware.js +9 -0
  16. package/core/compression/negotiate.js +23 -11
  17. package/core/kernel/app.js +2 -1
  18. package/core/kernel/index.js +5 -1
  19. package/core/orm/cache/fingerprint.js +23 -5
  20. package/core/orm/eager-loader.js +54 -9
  21. package/core/orm/index.js +38 -2
  22. package/core/orm/model.js +11 -2
  23. package/core/queue/adapters/database.js +46 -13
  24. package/core/queue/adapters/memory.js +21 -0
  25. package/core/queue/adapters/redis.js +21 -0
  26. package/core/queue/manager.js +46 -20
  27. package/core/validation/index.js +70 -0
  28. package/index.d.ts +99 -5
  29. package/index.js +7 -1
  30. package/package.json +9 -1
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
  32. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
  33. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  34. package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
  35. package/plugins/admin-panel/index.js +65 -6
  36. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  37. package/plugins/admin-panel/modules/dashboard.js +8 -1
  38. package/plugins/data-exchange/export-xlsx.js +28 -6
  39. package/plugins/data-exchange/import.js +45 -20
  40. package/plugins/data-exchange/index.js +5 -0
  41. package/plugins/data-exchange/parse-table.js +14 -5
  42. package/plugins/data-exchange/record-selection.js +2 -0
  43. package/plugins/index.js +4 -0
  44. package/plugins/mcp/auth.js +104 -0
  45. package/plugins/mcp/discovery.js +88 -0
  46. package/plugins/mcp/index.js +208 -0
  47. package/plugins/mcp/prompts.js +149 -0
  48. package/plugins/mcp/providers/orm.js +325 -0
  49. package/plugins/mcp/providers/services.js +190 -0
  50. package/plugins/mcp/providers/system.js +87 -0
  51. package/plugins/mcp/server.js +472 -0
  52. package/plugins/mcp/transports/sse.js +139 -0
  53. package/plugins/mcp/transports/stdio.js +108 -0
  54. package/src/file-router.js +184 -123
  55. package/src/njk-frontmatter.js +24 -14
  56. package/src/server.js +30 -12
  57. package/src/services/discovery.js +2 -1
  58. package/src/services/registry.js +2 -0
  59. package/src/services/validator.js +2 -53
  60. 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
- if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
400
- return m(custom.edit, { col, value, onChange, readonly, error });
401
- } else if (typeof custom.edit === 'function') {
402
- return custom.edit(value, onChange, col, readonly, error);
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
- if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
413
- return m(custom.edit, { col, value, onChange, readonly, error });
414
- } else if (typeof custom.edit === 'function') {
415
- return custom.edit(value, onChange, col, readonly, error);
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
- if (typeof custom.display === 'object' && custom.display.view) {
77
- return m(custom.display, { value, col, record });
78
- } else if (typeof custom.display === 'function') {
79
- return custom.display(value, record);
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
- URL.revokeObjectURL(url);
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
- URL.revokeObjectURL(url);
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 `window.__customFieldRenderers['${type}'] = {
480
- display: ${r.display ? serializeJs(r.display) : 'null'},
481
- edit: ${r.edit ? serializeJs(r.edit) : 'null'}
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.href = window.__ADMIN_PATH__ + result.result.url;
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
- document.body.removeChild(link);
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
- : (WidgetRenderers[widget.id] || WidgetRenderers.default).render(data),
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 hiddenSet = new Set(model.hidden || []);
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
- if (model.columns && model.columns.size > 0) {
14
- return Array.from(model.columns.keys()).filter((c) => !hidden.has(c));
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
- return Object.keys(model.schema.shape).filter((c) => !hidden.has(c));
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
- mode === 'insert' &&
102
- pkMeta?.autoIncrement &&
103
- (payload[pk] === undefined || payload[pk] === null || payload[pk] === '')
104
- ) {
105
- delete payload[pk];
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' && v !== null) {
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 || '').join('');
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
- if ('result' in v && v.result !== undefined) return v.result;
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
- if (!model.columns.has(upsertKey)) {
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 lowerToKey = new Map();
18
+ const exactLowerToKey = new Map();
19
+ const strippedToKey = new Map();
19
20
  for (const k of allowedKeys) {
20
- lowerToKey.set(String(k).toLowerCase().replace(/\s+/g, '_'), k);
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 norm = normalizeHeaderCell(headerRow[i]).toLowerCase().replace(/\s+/g, '_');
25
- if (!norm) {
27
+ const raw = normalizeHeaderCell(headerRow[i]);
28
+ if (!raw) {
26
29
  mapping.push(null);
27
30
  continue;
28
31
  }
29
- mapping.push(lowerToKey.get(norm) ?? null);
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
  }
@@ -70,6 +70,8 @@ async function resolveExportRecords(db, modelName, req) {
70
70
  if (record) records.push(record);
71
71
  }
72
72
  }
73
+ } else if (Array.isArray(idList) && idList.length === 0) {
74
+ records = [];
73
75
  } else {
74
76
  records = await repo.findAll();
75
77
  }
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
+ };