webspresso 0.0.90 → 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 (125) hide show
  1. package/README.md +98 -2509
  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 +199 -0
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +4 -0
  12. package/core/auth/middleware.js +9 -0
  13. package/core/compileSchema.js +1 -5
  14. package/core/compression/compressible.js +63 -0
  15. package/core/compression/compression-stream.js +59 -0
  16. package/core/compression/index.js +25 -0
  17. package/core/compression/middleware.js +310 -0
  18. package/core/compression/negotiate.js +165 -0
  19. package/core/errors/base.js +31 -0
  20. package/core/errors/domain.js +193 -0
  21. package/core/errors/http.js +133 -0
  22. package/core/errors/index.js +77 -0
  23. package/core/errors/middleware.js +162 -0
  24. package/core/errors/normalize.js +171 -0
  25. package/core/kernel/app.js +2 -1
  26. package/core/kernel/index.js +5 -1
  27. package/core/orm/cache/fingerprint.js +23 -5
  28. package/core/orm/complexity.js +120 -0
  29. package/core/orm/eager-loader.js +54 -9
  30. package/core/orm/events.js +64 -14
  31. package/core/orm/index.js +83 -12
  32. package/core/orm/model.js +13 -2
  33. package/core/orm/query-builder.js +30 -4
  34. package/core/orm/repository.js +57 -25
  35. package/core/orm/transaction.js +39 -5
  36. package/core/orm/utils.js +6 -3
  37. package/core/queue/adapters/database.js +317 -0
  38. package/core/queue/adapters/memory.js +176 -0
  39. package/core/queue/adapters/redis.js +357 -0
  40. package/core/queue/index.js +21 -0
  41. package/core/queue/job.js +103 -0
  42. package/core/queue/manager.js +413 -0
  43. package/core/realtime/adapters/redis.js +431 -0
  44. package/core/realtime/adapters/socket-io.js +203 -0
  45. package/core/realtime/adapters/sse.js +210 -0
  46. package/core/realtime/adapters/websocket.js +337 -0
  47. package/core/realtime/auth-manager.js +140 -0
  48. package/core/realtime/core.js +359 -0
  49. package/core/realtime/emitter.js +98 -0
  50. package/core/realtime/errors.js +71 -0
  51. package/core/realtime/index.js +79 -0
  52. package/core/realtime/reconnect-manager.js +118 -0
  53. package/core/realtime/registry.js +243 -0
  54. package/core/shutdown/http-adapter.js +134 -0
  55. package/core/shutdown/index.js +12 -0
  56. package/core/shutdown/shutdown-manager.js +312 -0
  57. package/core/ssr/index.js +13 -0
  58. package/core/ssr/stream.js +174 -0
  59. package/core/validation/index.js +70 -0
  60. package/index.d.ts +924 -53
  61. package/index.js +110 -1
  62. package/package.json +30 -2
  63. package/plugins/admin-panel/api.js +37 -4
  64. package/plugins/admin-panel/app.js +57 -17
  65. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
  66. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  67. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
  68. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  69. package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
  70. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  71. package/plugins/admin-panel/core/admin-module.js +2 -2
  72. package/plugins/admin-panel/core/api-extensions.js +149 -0
  73. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  74. package/plugins/admin-panel/index.js +155 -23
  75. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  76. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  77. package/plugins/admin-panel/modules/dashboard.js +8 -1
  78. package/plugins/admin-panel/modules/menu.js +85 -0
  79. package/plugins/admin-panel/modules/user-management.js +5 -2
  80. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  81. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  82. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  83. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  84. package/plugins/basic-auth/index.js +293 -0
  85. package/plugins/data-exchange/export-xlsx.js +28 -2
  86. package/plugins/data-exchange/import.js +55 -16
  87. package/plugins/data-exchange/index.js +19 -0
  88. package/plugins/data-exchange/parse-table.js +14 -5
  89. package/plugins/data-exchange/record-selection.js +2 -0
  90. package/plugins/email/index.js +14 -1
  91. package/plugins/index.js +33 -0
  92. package/plugins/mcp/auth.js +104 -0
  93. package/plugins/mcp/discovery.js +88 -0
  94. package/plugins/mcp/index.js +208 -0
  95. package/plugins/mcp/prompts.js +149 -0
  96. package/plugins/mcp/providers/orm.js +325 -0
  97. package/plugins/mcp/providers/services.js +190 -0
  98. package/plugins/mcp/providers/system.js +87 -0
  99. package/plugins/mcp/server.js +472 -0
  100. package/plugins/mcp/transports/sse.js +139 -0
  101. package/plugins/mcp/transports/stdio.js +108 -0
  102. package/plugins/queue/index.js +108 -0
  103. package/plugins/realtime/index.js +150 -0
  104. package/plugins/recaptcha/index.js +11 -0
  105. package/plugins/redirect/index.js +9 -1
  106. package/plugins/rest-resources/index.js +280 -11
  107. package/plugins/upload/index.js +16 -0
  108. package/src/app-context.js +46 -6
  109. package/src/file-router.js +366 -130
  110. package/src/helpers.js +20 -2
  111. package/src/njk-frontmatter.js +24 -14
  112. package/src/plugin-manager.js +124 -4
  113. package/src/server.js +332 -54
  114. package/src/services/builtins/auth.js +273 -0
  115. package/src/services/builtins/exchange.js +229 -0
  116. package/src/services/builtins/mail.js +134 -0
  117. package/src/services/builtins/media.js +196 -0
  118. package/src/services/builtins/system.js +221 -0
  119. package/src/services/discovery.js +141 -0
  120. package/src/services/executor.js +292 -0
  121. package/src/services/index.js +70 -0
  122. package/src/services/memoize.js +256 -0
  123. package/src/services/registry.js +290 -0
  124. package/src/services/validator.js +230 -0
  125. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Basic Auth Plugin — HTTP Basic Authentication (RFC 7617) for Webspresso
3
+ * Zero external dependencies (uses native Node.js crypto for timing-safe equality)
4
+ * @module plugins/basic-auth
5
+ */
6
+
7
+ const crypto = require('crypto');
8
+
9
+ /**
10
+ * Compare two strings in constant time to prevent timing attacks.
11
+ * Uses dummy buffer comparison if lengths differ to avoid leaking length via response timing.
12
+ *
13
+ * @param {string} a
14
+ * @param {string} b
15
+ * @returns {boolean}
16
+ */
17
+ function safeCompare(a, b) {
18
+ if (typeof a !== 'string' || typeof b !== 'string') {
19
+ return false;
20
+ }
21
+
22
+ const bufA = Buffer.from(a, 'utf8');
23
+ const bufB = Buffer.from(b, 'utf8');
24
+
25
+ if (bufA.length !== bufB.length) {
26
+ // Perform dummy timing-safe comparison with itself to preserve constant execution time
27
+ crypto.timingSafeEqual(bufA, bufA);
28
+ return false;
29
+ }
30
+
31
+ return crypto.timingSafeEqual(bufA, bufB);
32
+ }
33
+
34
+ /**
35
+ * Parse an HTTP Authorization header for Basic credentials.
36
+ *
37
+ * @param {string} header - e.g. "Basic dXNlcjpwYXNz"
38
+ * @returns {{ username: string, password: string } | null}
39
+ */
40
+ function parseBasicAuthHeader(header) {
41
+ if (!header || typeof header !== 'string') {
42
+ return null;
43
+ }
44
+
45
+ const parts = header.trim().split(/\s+/);
46
+ if (parts.length !== 2 || parts[0].toLowerCase() !== 'basic') {
47
+ return null;
48
+ }
49
+
50
+ try {
51
+ const decoded = Buffer.from(parts[1], 'base64').toString('utf8');
52
+ const colonIndex = decoded.indexOf(':');
53
+ if (colonIndex === -1) {
54
+ return null;
55
+ }
56
+
57
+ const username = decoded.slice(0, colonIndex);
58
+ const password = decoded.slice(colonIndex + 1);
59
+ return { username, password };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Extract credentials from request Authorization header or proxy headers
67
+ *
68
+ * @param {import('express').Request} req
69
+ * @returns {{ username: string, password: string } | null}
70
+ */
71
+ function extractCredentials(req) {
72
+ const header = req.headers.authorization || req.headers.Authorization;
73
+ return parseBasicAuthHeader(header);
74
+ }
75
+
76
+ /**
77
+ * Check if request matches path prefix list
78
+ *
79
+ * @param {string} path
80
+ * @param {string|string[]|boolean} routes
81
+ * @returns {boolean}
82
+ */
83
+ function shouldApplyToRoute(path, routes) {
84
+ if (routes === true || routes == null) return true;
85
+ const list = Array.isArray(routes) ? routes : [routes];
86
+ return list.some((prefix) => path.startsWith(prefix));
87
+ }
88
+
89
+ /**
90
+ * Check if request path should be skipped
91
+ *
92
+ * @param {import('express').Request} req
93
+ * @param {string[]} skipPaths
94
+ * @param {Function} [skipFn]
95
+ * @returns {boolean}
96
+ */
97
+ function isSkipped(req, skipPaths = [], skipFn) {
98
+ if (typeof skipFn === 'function' && skipFn(req)) {
99
+ return true;
100
+ }
101
+ const p = req.path || req.url || '';
102
+ if (Array.isArray(skipPaths) && skipPaths.some((prefix) => p.startsWith(prefix))) {
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+
108
+ /**
109
+ * Send 401 Unauthorized response with optional Basic challenge header
110
+ *
111
+ * @param {import('express').Request} req
112
+ * @param {import('express').Response} res
113
+ * @param {Object} config
114
+ */
115
+ function sendUnauthorized(req, res, config) {
116
+ res.statusCode = 401;
117
+
118
+ if (config.challenge !== false) {
119
+ const realm = String(config.realm || 'Restricted Area')
120
+ .split(/[\r\n]/)[0]
121
+ .replace(/"/g, '\\"');
122
+ res.setHeader('WWW-Authenticate', `Basic realm="${realm}"`);
123
+ }
124
+
125
+ if (typeof config.unauthorizedResponse === 'function') {
126
+ return config.unauthorizedResponse(req, res);
127
+ }
128
+
129
+ if (config.unauthorizedResponse && typeof config.unauthorizedResponse === 'object') {
130
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
131
+ return res.end(JSON.stringify(config.unauthorizedResponse));
132
+ }
133
+
134
+ if (typeof config.unauthorizedResponse === 'string') {
135
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
136
+ return res.end(config.unauthorizedResponse);
137
+ }
138
+
139
+ const acceptsJson = req.headers.accept && req.headers.accept.includes('application/json');
140
+ if (acceptsJson) {
141
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
142
+ return res.end(JSON.stringify({ error: 'Unauthorized', message: 'Authentication required.' }));
143
+ }
144
+
145
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
146
+ res.end('Unauthorized');
147
+ }
148
+
149
+ /**
150
+ * Create Basic Auth Express middleware
151
+ *
152
+ * @param {Object} [options]
153
+ * @param {Record<string, string>} [options.users] - Static username-password map
154
+ * @param {Function} [options.verify] - Custom verification function (username, password, req) => boolean | object | Promise<boolean | object>
155
+ * @param {string} [options.realm='Restricted Area'] - Realm name in WWW-Authenticate header
156
+ * @param {boolean} [options.challenge=true] - Whether to send WWW-Authenticate challenge header
157
+ * @param {string|string[]|boolean} [options.routes=true] - Route prefixes to protect (used in global mode)
158
+ * @param {string[]} [options.skipPaths] - Array of path prefixes to skip authentication
159
+ * @param {Function} [options.skip] - Custom predicate function (req) => boolean
160
+ * @param {Function|string|Object} [options.unauthorizedResponse] - Custom 401 response
161
+ * @param {Function} [options.onAuthenticated] - Callback hook (req, user) => void
162
+ * @returns {Function} Express middleware handler
163
+ */
164
+ function createBasicAuthMiddleware(options = {}) {
165
+ const config = {
166
+ realm: 'Restricted Area',
167
+ challenge: true,
168
+ routes: true,
169
+ skipPaths: [],
170
+ ...options,
171
+ };
172
+
173
+ return async (req, res, next) => {
174
+ const reqPath = req.path || req.url || '/';
175
+
176
+ if (!shouldApplyToRoute(reqPath, config.routes)) {
177
+ return next();
178
+ }
179
+
180
+ if (isSkipped(req, config.skipPaths, config.skip)) {
181
+ return next();
182
+ }
183
+
184
+ const credentials = extractCredentials(req);
185
+ if (!credentials) {
186
+ return sendUnauthorized(req, res, config);
187
+ }
188
+
189
+ const { username, password } = credentials;
190
+ let authResult = false;
191
+
192
+ if (typeof config.verify === 'function') {
193
+ try {
194
+ authResult = await config.verify(username, password, req);
195
+ } catch (err) {
196
+ return next(err);
197
+ }
198
+ } else if (config.users && typeof config.users === 'object') {
199
+ const expectedPassword = config.users[username];
200
+ if (typeof expectedPassword === 'string') {
201
+ authResult = safeCompare(password, expectedPassword);
202
+ }
203
+ }
204
+
205
+ if (!authResult) {
206
+ return sendUnauthorized(req, res, config);
207
+ }
208
+
209
+ // Attach authentication context to request
210
+ req.basicAuth = { username };
211
+ req.auth = req.auth || {};
212
+ const userObj = typeof authResult === 'object' && authResult !== null
213
+ ? authResult
214
+ : { username, type: 'basic' };
215
+
216
+ req.auth.user = userObj;
217
+ if (!req.user) {
218
+ req.user = userObj;
219
+ }
220
+
221
+ if (typeof config.onAuthenticated === 'function') {
222
+ try {
223
+ config.onAuthenticated(req, userObj);
224
+ } catch (err) {
225
+ return next(err);
226
+ }
227
+ }
228
+
229
+ next();
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Webspresso Basic Auth Plugin Factory
235
+ *
236
+ * @param {Object} [options] - Plugin configuration options
237
+ * @param {boolean} [options.global=false] - Whether to apply basic auth as a global middleware on the app
238
+ * @param {Record<string, string>} [options.users] - Default static username-password dictionary
239
+ * @param {Function} [options.verify] - Default custom verification function
240
+ * @param {string} [options.realm='Restricted Area'] - Default realm name
241
+ * @param {boolean} [options.challenge=true] - Default challenge header flag
242
+ * @param {string|string[]|boolean} [options.routes=true] - Route prefixes (when global: true)
243
+ * @param {string[]} [options.skipPaths] - Default skip path prefixes
244
+ * @param {Function} [options.skip] - Default skip predicate function
245
+ * @param {Function|string|Object} [options.unauthorizedResponse] - Custom 401 response
246
+ * @param {Function} [options.onAuthenticated] - Callback hook on successful login
247
+ * @returns {Object} Webspresso Plugin Definition
248
+ */
249
+ function basicAuthPlugin(options = {}) {
250
+ const { global: applyGlobal = false, ...pluginDefaults } = options;
251
+
252
+ return {
253
+ name: 'basic-auth',
254
+ version: '1.0.0',
255
+ description: 'HTTP Basic Authentication (RFC 7617) plugin with named middleware and global guard',
256
+ options,
257
+
258
+ api: {
259
+ createMiddleware: (routeOpts = {}) =>
260
+ createBasicAuthMiddleware({ ...pluginDefaults, ...routeOpts }),
261
+ parseHeader: parseBasicAuthHeader,
262
+ safeCompare,
263
+ extractCredentials,
264
+ },
265
+
266
+ register(ctx) {
267
+ // Register named middleware factory for file routes: middleware: ['basicAuth'] or [['basicAuth', { ... }]]
268
+ if (ctx.middlewares) {
269
+ ctx.middlewares.basicAuth = (routeOpts = {}) =>
270
+ createBasicAuthMiddleware({ ...pluginDefaults, ...routeOpts });
271
+ }
272
+
273
+ // Mount global middleware if global: true
274
+ if (applyGlobal && ctx.app) {
275
+ const globalMw = createBasicAuthMiddleware(options);
276
+ ctx.app.use(globalMw);
277
+ }
278
+ },
279
+ };
280
+ }
281
+
282
+ basicAuthPlugin.basicAuthPlugin = basicAuthPlugin;
283
+ basicAuthPlugin.createBasicAuthMiddleware = createBasicAuthMiddleware;
284
+ basicAuthPlugin.parseBasicAuthHeader = parseBasicAuthHeader;
285
+ basicAuthPlugin.safeCompare = safeCompare;
286
+ basicAuthPlugin.extractCredentials = extractCredentials;
287
+
288
+ module.exports = basicAuthPlugin;
289
+ module.exports.basicAuthPlugin = basicAuthPlugin;
290
+ module.exports.createBasicAuthMiddleware = createBasicAuthMiddleware;
291
+ module.exports.parseBasicAuthHeader = parseBasicAuthHeader;
292
+ module.exports.safeCompare = safeCompare;
293
+ module.exports.extractCredentials = extractCredentials;
@@ -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,8 +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 = Array.from(model.columns.keys()).filter((c) => !hiddenSet.has(c));
43
+ const columns = getModelExportColumns(model, clean);
19
44
 
20
45
  const wb = new ExcelJS.Workbook();
21
46
  const ws = wb.addWorksheet('Export', {
@@ -83,4 +108,5 @@ function createExportXlsxHandler(opts) {
83
108
  module.exports = {
84
109
  buildXlsxBuffer,
85
110
  createExportXlsxHandler,
111
+ getModelExportColumns,
86
112
  };
@@ -10,7 +10,18 @@ const { buildHeaderMapping, dataRowsToObjects } = require('./parse-table');
10
10
 
11
11
  function allowedImportColumns(model) {
12
12
  const hidden = new Set(model.hidden || []);
13
- 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
+ }
18
+ }
19
+ if (model.schema && model.schema.shape) {
20
+ for (const c of Object.keys(model.schema.shape)) {
21
+ if (!hidden.has(c)) cols.add(c);
22
+ }
23
+ }
24
+ return Array.from(cols);
14
25
  }
15
26
 
16
27
  /**
@@ -28,7 +39,15 @@ function coerceCell(raw, meta, column) {
28
39
  raw = t;
29
40
  }
30
41
 
31
- if (!meta || !meta.type) return raw;
42
+ if (!meta || !meta.type) {
43
+ if (typeof raw === 'string' && /^-?\d+(\.\d+)?$/.test(raw)) {
44
+ const num = Number(raw);
45
+ if (!Number.isNaN(num)) return num;
46
+ }
47
+ if (raw === 'true' || raw === 'yes') return true;
48
+ if (raw === 'false' || raw === 'no') return false;
49
+ return raw;
50
+ }
32
51
 
33
52
  switch (meta.type) {
34
53
  case 'bigint':
@@ -52,6 +71,14 @@ function coerceCell(raw, meta, column) {
52
71
  if (s === 'false' || s === '0' || s === 'no') return false;
53
72
  throw new Error(`Invalid boolean for ${column}`);
54
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
+ }
55
82
  case 'json': {
56
83
  if (typeof raw === 'object') return raw;
57
84
  try {
@@ -70,7 +97,7 @@ function buildPayloadForRow(model, data, mode) {
70
97
  const payload = {};
71
98
  for (const [col, raw] of Object.entries(data)) {
72
99
  if (!allowed.has(col)) continue;
73
- const meta = model.columns.get(col);
100
+ const meta = model.columns?.get ? model.columns.get(col) : undefined;
74
101
  let v;
75
102
  try {
76
103
  v = coerceCell(raw, meta, col);
@@ -81,14 +108,14 @@ function buildPayloadForRow(model, data, mode) {
81
108
  payload[col] = v;
82
109
  }
83
110
 
84
- const pk = model.primaryKey;
85
- const pkMeta = model.columns.get(pk);
86
- if (
87
- mode === 'insert' &&
88
- pkMeta?.autoIncrement &&
89
- (payload[pk] === undefined || payload[pk] === null || payload[pk] === '')
90
- ) {
91
- 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
+ }
92
119
  }
93
120
 
94
121
  return payload;
@@ -125,15 +152,26 @@ async function parseXlsxToRows(buffer) {
125
152
  }
126
153
 
127
154
  function cellValueToPlain(cell) {
155
+ if (!cell) return '';
128
156
  const v = cell.value;
129
157
  if (v === null || v === undefined) return '';
130
158
  if (v instanceof Date) return v.toISOString();
131
- if (typeof v === 'object' && v !== null) {
159
+ if (typeof v === 'object') {
160
+ if (v.error) return '';
132
161
  if ('text' in v && typeof v.text === 'string') return v.text;
133
162
  if ('richText' in v && Array.isArray(v.richText)) {
134
- 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();
135
173
  }
136
- if ('result' in v && v.result !== undefined) return v.result;
174
+ return '';
137
175
  }
138
176
  return v;
139
177
  }
@@ -177,7 +215,8 @@ function createImportHandler(opts) {
177
215
  let upsertKey = req.query.upsertKey || req.body?.upsertKey || model.primaryKey;
178
216
  upsertKey = String(upsertKey);
179
217
 
180
- if (!model.columns.has(upsertKey)) {
218
+ const allowedCols = allowedImportColumns(model);
219
+ if (!allowedCols.includes(upsertKey)) {
181
220
  return res.status(400).json({ error: `upsertKey "${upsertKey}" is not a column` });
182
221
  }
183
222
  if (model.hidden?.includes(upsertKey)) {
@@ -236,7 +275,7 @@ function createImportHandler(opts) {
236
275
  continue;
237
276
  }
238
277
 
239
- const keyMeta = model.columns.get(upsertKey);
278
+ const keyMeta = model.columns?.get ? model.columns.get(upsertKey) : undefined;
240
279
  let keyVal;
241
280
  try {
242
281
  keyVal = coerceCell(data[upsertKey], keyMeta, upsertKey);
@@ -33,8 +33,23 @@ function dataExchangePlugin(options = {}) {
33
33
  description: 'Admin spreadsheet import (CSV/XLSX) and Excel export',
34
34
  enabled,
35
35
 
36
+ register(ctx) {
37
+ if (!enabled) return;
38
+ const db = dbOption ?? ctx.db ?? ctx.app?.get?.('db') ?? null;
39
+ if (ctx.app && ctx.app.serviceRegistry) {
40
+ const { createExchangeServices } = require('../../src/services/builtins/exchange');
41
+ const exchangeServices = createExchangeServices({ db });
42
+ for (const [name, def] of Object.entries(exchangeServices)) {
43
+ if (!ctx.app.serviceRegistry.has(name)) {
44
+ ctx.app.serviceRegistry.register(name, def);
45
+ }
46
+ }
47
+ }
48
+ },
49
+
36
50
  onRoutesReady(ctx) {
37
51
  if (!enabled) return;
52
+ if (ctx.app && ctx.app._dataExchangeRoutesRegistered) return;
38
53
 
39
54
  const db = dbOption ?? ctx.db;
40
55
  if (!db) {
@@ -73,6 +88,10 @@ function dataExchangePlugin(options = {}) {
73
88
  },
74
89
  });
75
90
  }
91
+
92
+ if (ctx.app) {
93
+ ctx.app._dataExchangeRoutesRegistered = true;
94
+ }
76
95
  },
77
96
  };
78
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
  }
@@ -106,7 +106,20 @@ function emailPlugin(options = {}) {
106
106
  },
107
107
 
108
108
  register(ctx) {
109
- // noop — templates loaded at plugin init; register available for future hot-reload
109
+ if (ctx.app && ctx.app.serviceRegistry) {
110
+ const { createMailServices } = require('../../src/services/builtins/mail');
111
+ const mailServices = createMailServices({
112
+ emailService,
113
+ registry,
114
+ db,
115
+ tableName,
116
+ });
117
+ for (const [name, def] of Object.entries(mailServices)) {
118
+ if (!ctx.app.serviceRegistry.has(name)) {
119
+ ctx.app.serviceRegistry.register(name, def);
120
+ }
121
+ }
122
+ }
110
123
  },
111
124
 
112
125
  onRoutesReady(ctx) {
package/plugins/index.js CHANGED
@@ -25,6 +25,21 @@ const emailPlugin = require('./email');
25
25
  const contentPlugin = require('./content');
26
26
  const csrfPlugin = require('./csrf');
27
27
  const corsPlugin = require('./cors');
28
+ const { basicAuthPlugin, createBasicAuthMiddleware } = require('./basic-auth');
29
+ const {
30
+ realtimePlugin,
31
+ realtime,
32
+ createRealtime,
33
+ websocket,
34
+ createWebSocketAdapter,
35
+ sse,
36
+ createSseAdapter,
37
+ socketIo,
38
+ createSocketIoAdapter,
39
+ redis,
40
+ createRedisAdapter,
41
+ } = require('./realtime');
42
+ const { mcpPlugin, createMcpServer, McpServer } = require('./mcp');
28
43
 
29
44
  module.exports = {
30
45
  sitemapPlugin,
@@ -49,5 +64,23 @@ module.exports = {
49
64
  contentPlugin,
50
65
  csrfPlugin,
51
66
  corsPlugin,
67
+ basicAuthPlugin,
68
+ createBasicAuthMiddleware,
69
+ realtimePlugin,
70
+ realtime,
71
+ createRealtime,
72
+ websocket,
73
+ createWebSocketAdapter,
74
+ sse,
75
+ createSseAdapter,
76
+ socketIo,
77
+ createSocketIoAdapter,
78
+ redis,
79
+ createRedisAdapter,
80
+ queuePlugin: require('./queue'),
81
+ mcpPlugin,
82
+ createMcpServer,
83
+ McpServer,
52
84
  };
53
85
 
86
+