webspresso 0.0.91 → 0.0.94

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 (86) 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/polar-migrate.js +50 -0
  9. package/bin/commands/seed.js +7 -1
  10. package/bin/commands/service.js +8 -2
  11. package/bin/commands/skill.js +7 -1
  12. package/bin/webspresso.js +4 -0
  13. package/core/auth/tokens.js +16 -0
  14. package/core/compileSchema.js +1 -5
  15. package/core/compression/compressible.js +17 -5
  16. package/core/compression/compression-stream.js +1 -1
  17. package/core/compression/middleware.js +9 -0
  18. package/core/compression/negotiate.js +23 -11
  19. package/core/kernel/app.js +2 -1
  20. package/core/kernel/index.js +5 -1
  21. package/core/orm/cache/fingerprint.js +23 -5
  22. package/core/orm/cache/layer.js +1 -1
  23. package/core/orm/eager-loader.js +54 -9
  24. package/core/orm/index.js +42 -3
  25. package/core/orm/model.js +11 -2
  26. package/core/orm/transaction.js +3 -1
  27. package/core/queue/adapters/database.js +46 -13
  28. package/core/queue/adapters/memory.js +21 -0
  29. package/core/queue/adapters/redis.js +21 -0
  30. package/core/queue/manager.js +46 -20
  31. package/core/ssr/stream.js +58 -14
  32. package/core/validation/index.js +70 -0
  33. package/index.d.ts +99 -5
  34. package/index.js +7 -1
  35. package/package.json +9 -1
  36. package/plugins/admin-panel/auth.js +10 -0
  37. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
  38. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
  39. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  40. package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
  41. package/plugins/admin-panel/index.js +65 -6
  42. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  43. package/plugins/admin-panel/modules/dashboard.js +8 -1
  44. package/plugins/content/index.js +5 -3
  45. package/plugins/data-exchange/export-xlsx.js +28 -6
  46. package/plugins/data-exchange/import.js +120 -58
  47. package/plugins/data-exchange/index.js +5 -0
  48. package/plugins/data-exchange/parse-table.js +14 -5
  49. package/plugins/data-exchange/record-selection.js +2 -0
  50. package/plugins/index.js +6 -0
  51. package/plugins/mcp/auth.js +104 -0
  52. package/plugins/mcp/discovery.js +88 -0
  53. package/plugins/mcp/index.js +208 -0
  54. package/plugins/mcp/prompts.js +149 -0
  55. package/plugins/mcp/providers/orm.js +325 -0
  56. package/plugins/mcp/providers/services.js +190 -0
  57. package/plugins/mcp/providers/system.js +87 -0
  58. package/plugins/mcp/server.js +472 -0
  59. package/plugins/mcp/transports/sse.js +156 -0
  60. package/plugins/mcp/transports/stdio.js +108 -0
  61. package/plugins/polar/.env.example +12 -0
  62. package/plugins/polar/CHANGELOG.md +38 -0
  63. package/plugins/polar/README.md +158 -0
  64. package/plugins/polar/index.js +144 -0
  65. package/plugins/polar/src/api.js +54 -0
  66. package/plugins/polar/src/checkout.js +111 -0
  67. package/plugins/polar/src/config.js +88 -0
  68. package/plugins/polar/src/env.schema.js +36 -0
  69. package/plugins/polar/src/middleware.js +37 -0
  70. package/plugins/polar/src/migration.js +58 -0
  71. package/plugins/polar/src/nunjucks-filters.js +56 -0
  72. package/plugins/polar/src/portal.js +51 -0
  73. package/plugins/polar/src/rate-limit.js +70 -0
  74. package/plugins/polar/src/routes.js +212 -0
  75. package/plugins/polar/src/sync.js +138 -0
  76. package/plugins/polar/src/urls.js +117 -0
  77. package/plugins/polar/src/user-resolver.js +195 -0
  78. package/plugins/polar/src/webhooks.js +248 -0
  79. package/src/file-router.js +203 -125
  80. package/src/helpers.js +60 -13
  81. package/src/njk-frontmatter.js +24 -14
  82. package/src/server.js +30 -12
  83. package/src/services/discovery.js +2 -1
  84. package/src/services/registry.js +2 -0
  85. package/src/services/validator.js +2 -53
  86. package/templates/skills/webspresso-usage/REFERENCE-framework.md +7 -4
package/index.d.ts CHANGED
@@ -34,7 +34,21 @@ export interface CreateAppOptions {
34
34
  version?: string;
35
35
  manifestPath?: string;
36
36
  prefix?: string;
37
- }; /**
37
+ publicDir?: string;
38
+ };
39
+ /**
40
+ * If truthy, route load() return values for stylesheets and scripts are promoted to pageHead
41
+ */
42
+ pageAssets?:
43
+ | boolean
44
+ | {
45
+ enabled?: boolean;
46
+ stylesheets?: boolean;
47
+ scripts?: boolean;
48
+ };
49
+ /** Trust proxy configuration (default: 1) */
50
+ trustProxy?: boolean | number | string;
51
+ /**
38
52
  * Custom 404 / 500 / 503 handlers. File-based route errors are forwarded with `next(err)` and hit `serverError` / `timeout`.
39
53
  * When `serverError` / `timeout` is a template path, it is not used for paths under `/api` (default JSON instead).
40
54
  */
@@ -56,6 +70,7 @@ export interface CreateAppOptions {
56
70
  port?: number;
57
71
  shutdown?: ShutdownOptions;
58
72
  compression?: boolean | CompressionOptions;
73
+ trustProxy?: boolean | number | string;
59
74
  [key: string]: unknown;
60
75
  };
61
76
  /** Opt-in Alpine / swup assets under `/__webspresso/client-runtime/*`. Env: WEBSPRESSO_ALPINE, WEBSPRESSO_SWUP. */
@@ -76,7 +91,14 @@ export interface ServiceContext {
76
91
  req?: Request;
77
92
  res?: Response;
78
93
  db?: DatabaseInstance | null;
79
- service?: <T = unknown>(name: string, input?: unknown, options?: unknown) => Promise<T>;
94
+ fsy?: Record<string, unknown>;
95
+ locale?: string;
96
+ t?: unknown;
97
+ service?: {
98
+ <T = unknown>(name: string, input?: unknown, options?: unknown): Promise<T>;
99
+ invalidate?: (name: string, input?: unknown, ctx?: unknown) => boolean;
100
+ clearCache?: (name?: string) => boolean;
101
+ };
80
102
  [key: string]: unknown;
81
103
  }
82
104
 
@@ -89,9 +111,16 @@ export interface ServiceDefinition<TInput = unknown, TOutput = unknown> {
89
111
  schema?: import('zod').ZodType<any> | Record<string, unknown> | ((input: unknown) => unknown) | null;
90
112
  handler: ServiceHandler<TInput, TOutput>;
91
113
  metadata?: Record<string, unknown>;
92
- auth?: boolean | string | string[];
93
- cache?: boolean | string | number;
94
- timeout?: number;
114
+ auth?: boolean | string | string[] | ((user: unknown, ctx: ServiceContext) => boolean | Promise<boolean>);
115
+ cache?:
116
+ | boolean
117
+ | string
118
+ | number
119
+ | {
120
+ ttl?: string | number;
121
+ key?: string | ((input: unknown, ctx: ServiceContext) => string);
122
+ };
123
+ timeout?: number | string;
95
124
  transaction?: boolean;
96
125
  [key: string]: unknown;
97
126
  }
@@ -274,6 +303,7 @@ export interface WebspressoApplication extends Application {
274
303
  server?: unknown;
275
304
  readonly isShuttingDown: boolean;
276
305
  shutdownManager: ShutdownManager;
306
+ serviceRegistry: ServiceRegistry;
277
307
  onShutdown(fn: () => Promise<void> | void): this;
278
308
  close(reason?: string): Promise<void>;
279
309
  enableShutdownHooks(): this;
@@ -1583,4 +1613,68 @@ export const ssr: {
1583
1613
  renderStream: typeof renderStream;
1584
1614
  };
1585
1615
 
1616
+ // --- Model Context Protocol (MCP) Plugin ---
1617
+
1618
+ export interface McpPluginOptions {
1619
+ path?: string;
1620
+ enabled?: boolean;
1621
+ auth?: {
1622
+ token?: string;
1623
+ localhostOnly?: boolean;
1624
+ verify?: (req: import('express').Request) => boolean | Promise<boolean>;
1625
+ };
1626
+ services?: boolean | {
1627
+ include?: string[];
1628
+ exclude?: string[];
1629
+ readOnly?: boolean;
1630
+ role?: string | string[];
1631
+ };
1632
+ orm?: boolean | {
1633
+ readOnly?: boolean;
1634
+ models?: string[];
1635
+ };
1636
+ system?: boolean;
1637
+ tools?: Array<{
1638
+ name: string;
1639
+ description?: string;
1640
+ inputSchema?: Record<string, unknown>;
1641
+ handler: (args: any, ctx: any) => Promise<any> | any;
1642
+ }>;
1643
+ resources?: Array<{
1644
+ uri?: string;
1645
+ uriTemplate?: string;
1646
+ name?: string;
1647
+ description?: string;
1648
+ mimeType?: string;
1649
+ handler: (uri: string, paramsOrCtx: any, ctx?: any) => Promise<any> | any;
1650
+ }>;
1651
+ prompts?: Array<{
1652
+ name: string;
1653
+ description?: string;
1654
+ arguments?: Array<{ name: string; description?: string; required?: boolean }>;
1655
+ handler: (args: any, ctx: any) => Promise<any> | any;
1656
+ }>;
1657
+ discovery?: boolean;
1658
+ name?: string;
1659
+ version?: string;
1660
+ instructions?: string;
1661
+ }
1662
+
1663
+ export class McpServer {
1664
+ name: string;
1665
+ version: string;
1666
+ instructions: string;
1667
+ constructor(options?: { name?: string; version?: string; instructions?: string; context?: Record<string, unknown> });
1668
+ registerTool(toolDef: any): this;
1669
+ registerTools(toolDefs: any[]): this;
1670
+ unregisterTool(name: string): boolean;
1671
+ registerResource(resourceDef: any): this;
1672
+ registerResourceTemplate(templateDef: any): this;
1673
+ registerPrompt(promptDef: any): this;
1674
+ handleMessage(message: any, callContext?: any): Promise<any>;
1675
+ }
1676
+
1677
+ export function mcpPlugin(options?: McpPluginOptions): WebspressoPlugin;
1678
+ export function createMcpServer(options?: McpPluginOptions): McpServer;
1679
+
1586
1680
 
package/index.js CHANGED
@@ -55,7 +55,7 @@ const {
55
55
  // ORM exports (lazy loaded)
56
56
  const orm = require('./core/orm');
57
57
 
58
- /** Application kernel (event bus, plugin shell, view resolver, flows). Use `kernel.createApp`; not the framework SSR `createApp`. */
58
+ /** Standalone experimental application kernel (event bus, plugin shell, view resolver, flows). Distinct from framework SSR `createApp`. */
59
59
  const kernel = require('./core/kernel');
60
60
 
61
61
  // Built-in plugins
@@ -87,6 +87,9 @@ const {
87
87
  socketIo,
88
88
  redis,
89
89
  createRedisAdapter,
90
+ mcpPlugin,
91
+ createMcpServer,
92
+ McpServer,
90
93
  } = require('./plugins');
91
94
 
92
95
  const content = require('./core/content');
@@ -237,6 +240,9 @@ module.exports = {
237
240
  queue: require('./core/queue'),
238
241
  createQueueManager: require('./core/queue').createQueueManager,
239
242
  QueueManager: require('./core/queue').QueueManager,
243
+ mcpPlugin,
244
+ createMcpServer,
245
+ McpServer,
240
246
 
241
247
  // Schema-driven CMS core (framework-agnostic)
242
248
  content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.91",
3
+ "version": "0.0.94",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -42,6 +42,10 @@
42
42
  "bench:ci:local": "npm run bench:baseline && npm run bench:ci",
43
43
  "check:types": "tsc --project tests/ts-smoke/tsconfig.json",
44
44
  "rebuild:native": "npm rebuild better-sqlite3 bcrypt sharp",
45
+ "docs:dev": "docboot dev ./docs",
46
+ "docs:build": "docboot build ./docs",
47
+ "docs:serve": "docboot serve dist",
48
+ "docs:doctor": "docboot doctor ./docs",
45
49
  "release": "release-it"
46
50
  },
47
51
  "keywords": [
@@ -134,6 +138,7 @@
134
138
  "@vitest/coverage-v8": "^3.0.0",
135
139
  "better-sqlite3": "^11.10.0",
136
140
  "chokidar": "^3.5.3",
141
+ "docboot": "^0.3.0",
137
142
  "dotenv": "^16.3.1",
138
143
  "express-rate-limit": "^8.4.1",
139
144
  "release-it": "^20.2.1",
@@ -149,5 +154,8 @@
149
154
  "tar": "^7.5.11",
150
155
  "undici": "^6.24.0",
151
156
  "uuid": "^11.1.1"
157
+ },
158
+ "allowScripts": {
159
+ "better-sqlite3@11.10.0": true
152
160
  }
153
161
  }
@@ -68,6 +68,9 @@ async function verifyPassword(password, hash, compare) {
68
68
  return await compare(password, hash);
69
69
  }
70
70
 
71
+ // Precomputed dummy bcrypt hash (cost factor 10) to mitigate timing attacks / user enumeration
72
+ const DUMMY_HASH = '$2b$10$wKzS8Vp0qXn8mY2bQv5eUu6R.kZbN8vA8Y7o7u4d1w2x3y4z5a6b7';
73
+
71
74
  /**
72
75
  * Login user and set session
73
76
  * @param {Object} adminUserRepo - AdminUser repository
@@ -81,6 +84,13 @@ async function login(adminUserRepo, email, password, compare) {
81
84
  const user = await adminUserRepo.query().where('email', email).first();
82
85
 
83
86
  if (!user || !user.active) {
87
+ if (typeof compare === 'function') {
88
+ try {
89
+ await Promise.resolve(compare(password, DUMMY_HASH));
90
+ } catch {
91
+ // Ignore timing dummy comparison errors
92
+ }
93
+ }
84
94
  return null;
85
95
  }
86
96
 
@@ -59,10 +59,15 @@ async function downloadDataExchangeXlsx(modelName, payload) {
59
59
  const blob = await res.blob();
60
60
  var url = URL.createObjectURL(blob);
61
61
  var a = document.createElement('a');
62
+ a.style.display = 'none';
62
63
  a.href = url;
63
64
  a.download = modelName + '-export.xlsx';
65
+ document.body.appendChild(a);
64
66
  a.click();
65
- URL.revokeObjectURL(url);
67
+ setTimeout(function () {
68
+ if (a.parentNode) a.parentNode.removeChild(a);
69
+ URL.revokeObjectURL(url);
70
+ }, 1000);
66
71
  }
67
72
 
68
73
  // Helper: Capitalize first letter of each word
@@ -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
  },
@@ -59,12 +59,14 @@ function contentPlugin(options = {}) {
59
59
  if (!inlineEdit || !isAdminSession(req, adminPath)) {
60
60
  return html;
61
61
  }
62
+ const nonce = req.res?.locals?.cspNonce || req.res?.locals?.nonce || req.cspNonce || '';
63
+ const nonceAttr = nonce ? ` nonce="${nonce}"` : '';
62
64
  const configScript =
63
- '<script>window.__WS_CONTENT__=' +
65
+ `<script${nonceAttr}>window.__WS_CONTENT__=` +
64
66
  JSON.stringify({ enabled: true, adminPath }) +
65
67
  ';</script>';
66
- const styleTag = '<style id="ws-content-inline-css">' + INLINE_EDIT_CSS + '</style>';
67
- const scriptTag = '<script id="ws-content-inline-edit">' + INLINE_EDIT_JS + '</script>';
68
+ const styleTag = `<style id="ws-content-inline-css"${nonceAttr}>` + INLINE_EDIT_CSS + '</style>';
69
+ const scriptTag = `<script id="ws-content-inline-edit"${nonceAttr}>` + INLINE_EDIT_JS + '</script>';
68
70
  const bundle = configScript + styleTag + scriptTag;
69
71
  if (html.includes('</body>')) {
70
72
  return html.replace('</body>', bundle + '</body>');
@@ -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
  };