@intranefr/superbackend 1.4.3

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 (188) hide show
  1. package/.commiat +4 -0
  2. package/.env.example +47 -0
  3. package/README.md +110 -0
  4. package/index.js +94 -0
  5. package/package.json +67 -0
  6. package/public/css/styles.css +139 -0
  7. package/public/js/animations.js +41 -0
  8. package/sdk/error-tracking/browser/package.json +16 -0
  9. package/sdk/error-tracking/browser/src/core.js +270 -0
  10. package/sdk/error-tracking/browser/src/embed.js +18 -0
  11. package/sdk/error-tracking/browser/src/index.js +1 -0
  12. package/server.js +5 -0
  13. package/src/admin/endpointRegistry.js +300 -0
  14. package/src/controllers/admin.controller.js +321 -0
  15. package/src/controllers/adminAssets.controller.js +530 -0
  16. package/src/controllers/adminAssetsStorage.controller.js +260 -0
  17. package/src/controllers/adminEjsVirtual.controller.js +354 -0
  18. package/src/controllers/adminFeatureFlags.controller.js +155 -0
  19. package/src/controllers/adminHeadless.controller.js +1071 -0
  20. package/src/controllers/adminI18n.controller.js +604 -0
  21. package/src/controllers/adminJsonConfigs.controller.js +97 -0
  22. package/src/controllers/adminLlm.controller.js +273 -0
  23. package/src/controllers/adminMigration.controller.js +257 -0
  24. package/src/controllers/adminSeoConfig.controller.js +515 -0
  25. package/src/controllers/adminStats.controller.js +121 -0
  26. package/src/controllers/adminUploadNamespaces.controller.js +208 -0
  27. package/src/controllers/assets.controller.js +248 -0
  28. package/src/controllers/auth.controller.js +93 -0
  29. package/src/controllers/billing.controller.js +223 -0
  30. package/src/controllers/featureFlags.controller.js +35 -0
  31. package/src/controllers/forms.controller.js +217 -0
  32. package/src/controllers/globalSettings.controller.js +252 -0
  33. package/src/controllers/headlessCrud.controller.js +126 -0
  34. package/src/controllers/i18n.controller.js +12 -0
  35. package/src/controllers/invite.controller.js +249 -0
  36. package/src/controllers/jsonConfigs.controller.js +19 -0
  37. package/src/controllers/metrics.controller.js +149 -0
  38. package/src/controllers/notificationAdmin.controller.js +264 -0
  39. package/src/controllers/notifications.controller.js +131 -0
  40. package/src/controllers/org.controller.js +357 -0
  41. package/src/controllers/orgAdmin.controller.js +491 -0
  42. package/src/controllers/stripeAdmin.controller.js +410 -0
  43. package/src/controllers/user.controller.js +361 -0
  44. package/src/controllers/userAdmin.controller.js +277 -0
  45. package/src/controllers/waitingList.controller.js +167 -0
  46. package/src/controllers/webhook.controller.js +200 -0
  47. package/src/middleware/auth.js +66 -0
  48. package/src/middleware/errorCapture.js +170 -0
  49. package/src/middleware/headlessApiTokenAuth.js +57 -0
  50. package/src/middleware/org.js +108 -0
  51. package/src/middleware.js +901 -0
  52. package/src/models/ActionEvent.js +31 -0
  53. package/src/models/ActivityLog.js +41 -0
  54. package/src/models/Asset.js +84 -0
  55. package/src/models/AuditEvent.js +93 -0
  56. package/src/models/EmailLog.js +28 -0
  57. package/src/models/ErrorAggregate.js +72 -0
  58. package/src/models/FormSubmission.js +41 -0
  59. package/src/models/GlobalSetting.js +38 -0
  60. package/src/models/HeadlessApiToken.js +24 -0
  61. package/src/models/HeadlessModelDefinition.js +41 -0
  62. package/src/models/I18nEntry.js +77 -0
  63. package/src/models/I18nLocale.js +33 -0
  64. package/src/models/Invite.js +70 -0
  65. package/src/models/JsonConfig.js +46 -0
  66. package/src/models/Notification.js +60 -0
  67. package/src/models/Organization.js +57 -0
  68. package/src/models/OrganizationMember.js +43 -0
  69. package/src/models/StripeCatalogItem.js +77 -0
  70. package/src/models/StripeWebhookEvent.js +57 -0
  71. package/src/models/User.js +89 -0
  72. package/src/models/VirtualEjsFile.js +60 -0
  73. package/src/models/VirtualEjsFileVersion.js +43 -0
  74. package/src/models/VirtualEjsGroupChange.js +32 -0
  75. package/src/models/WaitingList.js +41 -0
  76. package/src/models/Webhook.js +63 -0
  77. package/src/models/Workflow.js +29 -0
  78. package/src/models/WorkflowExecution.js +12 -0
  79. package/src/routes/admin.routes.js +26 -0
  80. package/src/routes/adminAssets.routes.js +28 -0
  81. package/src/routes/adminAssetsStorage.routes.js +13 -0
  82. package/src/routes/adminAudit.routes.js +196 -0
  83. package/src/routes/adminEjsVirtual.routes.js +17 -0
  84. package/src/routes/adminErrors.routes.js +164 -0
  85. package/src/routes/adminFeatureFlags.routes.js +12 -0
  86. package/src/routes/adminHeadless.routes.js +38 -0
  87. package/src/routes/adminI18n.routes.js +22 -0
  88. package/src/routes/adminJsonConfigs.routes.js +15 -0
  89. package/src/routes/adminLlm.routes.js +12 -0
  90. package/src/routes/adminMigration.routes.js +81 -0
  91. package/src/routes/adminSeoConfig.routes.js +20 -0
  92. package/src/routes/adminUploadNamespaces.routes.js +13 -0
  93. package/src/routes/assets.routes.js +21 -0
  94. package/src/routes/auth.routes.js +12 -0
  95. package/src/routes/billing.routes.js +11 -0
  96. package/src/routes/errorTracking.routes.js +31 -0
  97. package/src/routes/featureFlags.routes.js +9 -0
  98. package/src/routes/forms.routes.js +9 -0
  99. package/src/routes/formsAdmin.routes.js +13 -0
  100. package/src/routes/globalSettings.routes.js +18 -0
  101. package/src/routes/headless.routes.js +15 -0
  102. package/src/routes/i18n.routes.js +8 -0
  103. package/src/routes/invite.routes.js +9 -0
  104. package/src/routes/jsonConfigs.routes.js +8 -0
  105. package/src/routes/log.routes.js +111 -0
  106. package/src/routes/metrics.routes.js +9 -0
  107. package/src/routes/notificationAdmin.routes.js +15 -0
  108. package/src/routes/notifications.routes.js +12 -0
  109. package/src/routes/org.routes.js +31 -0
  110. package/src/routes/orgAdmin.routes.js +20 -0
  111. package/src/routes/publicAssets.routes.js +7 -0
  112. package/src/routes/stripeAdmin.routes.js +20 -0
  113. package/src/routes/user.routes.js +22 -0
  114. package/src/routes/userAdmin.routes.js +15 -0
  115. package/src/routes/waitingList.routes.js +13 -0
  116. package/src/routes/waitingListAdmin.routes.js +9 -0
  117. package/src/routes/webhook.routes.js +32 -0
  118. package/src/routes/workflowWebhook.routes.js +54 -0
  119. package/src/routes/workflows.routes.js +110 -0
  120. package/src/services/assets.service.js +110 -0
  121. package/src/services/audit.service.js +62 -0
  122. package/src/services/auditLogger.js +165 -0
  123. package/src/services/ejsVirtual.service.js +614 -0
  124. package/src/services/email.service.js +351 -0
  125. package/src/services/errorLogger.js +221 -0
  126. package/src/services/featureFlags.service.js +202 -0
  127. package/src/services/forms.service.js +214 -0
  128. package/src/services/globalSettings.service.js +49 -0
  129. package/src/services/headlessApiTokens.service.js +158 -0
  130. package/src/services/headlessCrypto.service.js +31 -0
  131. package/src/services/headlessModels.service.js +356 -0
  132. package/src/services/i18n.service.js +314 -0
  133. package/src/services/i18nInferredKeys.service.js +337 -0
  134. package/src/services/jsonConfigs.service.js +392 -0
  135. package/src/services/llm.service.js +749 -0
  136. package/src/services/migration.service.js +581 -0
  137. package/src/services/migrationAssets/fsLocal.js +58 -0
  138. package/src/services/migrationAssets/index.js +134 -0
  139. package/src/services/migrationAssets/s3.js +75 -0
  140. package/src/services/migrationAssets/sftp.js +92 -0
  141. package/src/services/notification.service.js +212 -0
  142. package/src/services/objectStorage.service.js +514 -0
  143. package/src/services/seoConfig.service.js +402 -0
  144. package/src/services/storage.js +150 -0
  145. package/src/services/stripe.service.js +185 -0
  146. package/src/services/stripeHelper.service.js +264 -0
  147. package/src/services/uploadNamespaces.service.js +326 -0
  148. package/src/services/webhook.service.js +157 -0
  149. package/src/services/workflow.service.js +271 -0
  150. package/src/utils/asyncHandler.js +5 -0
  151. package/src/utils/encryption.js +80 -0
  152. package/src/utils/jwt.js +40 -0
  153. package/src/utils/orgRoles.js +156 -0
  154. package/src/utils/validation.js +26 -0
  155. package/src/utils/webhookRetry.js +93 -0
  156. package/views/admin-assets.ejs +444 -0
  157. package/views/admin-audit.ejs +283 -0
  158. package/views/admin-coolify-deploy.ejs +207 -0
  159. package/views/admin-dashboard-home.ejs +291 -0
  160. package/views/admin-dashboard.ejs +397 -0
  161. package/views/admin-ejs-virtual.ejs +280 -0
  162. package/views/admin-errors.ejs +368 -0
  163. package/views/admin-feature-flags.ejs +390 -0
  164. package/views/admin-forms.ejs +526 -0
  165. package/views/admin-global-settings.ejs +436 -0
  166. package/views/admin-headless.ejs +2020 -0
  167. package/views/admin-i18n-locales.ejs +221 -0
  168. package/views/admin-i18n.ejs +728 -0
  169. package/views/admin-json-configs.ejs +410 -0
  170. package/views/admin-llm.ejs +884 -0
  171. package/views/admin-metrics.ejs +274 -0
  172. package/views/admin-migration.ejs +814 -0
  173. package/views/admin-notifications.ejs +430 -0
  174. package/views/admin-organizations.ejs +984 -0
  175. package/views/admin-seo-config.ejs +673 -0
  176. package/views/admin-stripe-pricing.ejs +558 -0
  177. package/views/admin-test.ejs +342 -0
  178. package/views/admin-users.ejs +452 -0
  179. package/views/admin-waiting-list.ejs +547 -0
  180. package/views/admin-webhooks.ejs +329 -0
  181. package/views/admin-workflows.ejs +310 -0
  182. package/views/partials/admin-assets-script.ejs +2022 -0
  183. package/views/partials/admin-test-sidebar.ejs +14 -0
  184. package/views/partials/dashboard/nav-items.ejs +66 -0
  185. package/views/partials/dashboard/palette.ejs +63 -0
  186. package/views/partials/dashboard/sidebar.ejs +21 -0
  187. package/views/partials/dashboard/tab-bar.ejs +26 -0
  188. package/views/partials/footer.ejs +3 -0
@@ -0,0 +1,1071 @@
1
+ const {
2
+ listModelDefinitions,
3
+ getModelDefinitionByCode,
4
+ createModelDefinition,
5
+ updateModelDefinition,
6
+ disableModelDefinition,
7
+ getDynamicModel,
8
+ } = require('../services/headlessModels.service');
9
+
10
+ const llmService = require('../services/llm.service');
11
+ const { getSettingValue } = require('../services/globalSettings.service');
12
+ const { createAuditEvent, getBasicAuthActor } = require('../services/audit.service');
13
+ const axios = require('axios');
14
+ const { logAudit, scrubObject } = require('../services/auditLogger');
15
+
16
+ const {
17
+ listApiTokens,
18
+ getApiTokenById,
19
+ createApiToken,
20
+ updateApiToken,
21
+ deleteApiToken,
22
+ } = require('../services/headlessApiTokens.service');
23
+
24
+ function handleServiceError(res, error) {
25
+ const msg = error?.message || 'Operation failed';
26
+ const code = error?.code;
27
+
28
+ if (code === 'VALIDATION') return res.status(400).json({ error: msg });
29
+ if (code === 'NOT_FOUND') return res.status(404).json({ error: msg });
30
+ if (code === 'CONFLICT') return res.status(409).json({ error: msg });
31
+
32
+ return res.status(500).json({ error: msg });
33
+ }
34
+
35
+ function toSafeJsonError(error) {
36
+ const msg = error?.message || 'Operation failed';
37
+ const code = error?.code;
38
+ if (code === 'VALIDATION') return { status: 400, body: { error: msg } };
39
+ if (code === 'NOT_FOUND') return { status: 404, body: { error: msg } };
40
+ if (code === 'CONFLICT') return { status: 409, body: { error: msg } };
41
+ return { status: 500, body: { error: msg } };
42
+ }
43
+
44
+ function safeJsonParse(value) {
45
+ try {
46
+ return JSON.parse(String(value || ''));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function truncateStringByBytes(value, maxBytes) {
53
+ const str = String(value || '');
54
+ if (!maxBytes || maxBytes <= 0) return '';
55
+ const buf = Buffer.from(str, 'utf8');
56
+ if (buf.length <= maxBytes) return str;
57
+ return buf.slice(0, maxBytes).toString('utf8');
58
+ }
59
+
60
+ function sanitizeAndTruncateMeta(value, maxBytes) {
61
+ const scrubbed = scrubObject(value);
62
+ let json;
63
+ try {
64
+ json = JSON.stringify(scrubbed);
65
+ } catch {
66
+ json = JSON.stringify({ error: 'Non-serializable response body' });
67
+ }
68
+
69
+ const buf = Buffer.from(json, 'utf8');
70
+ if (buf.length <= maxBytes) {
71
+ return { truncated: false, value: scrubbed };
72
+ }
73
+
74
+ return {
75
+ truncated: true,
76
+ value: {
77
+ _truncated: true,
78
+ _maxBytes: maxBytes,
79
+ preview: truncateStringByBytes(json, maxBytes),
80
+ },
81
+ };
82
+ }
83
+
84
+ function buildLoopbackBaseUrl(req) {
85
+ const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
86
+ const host = req.get('host');
87
+ const basePrefix = String(req.baseUrl || '').replace(/\/api\/admin\/headless$/, '');
88
+ return `${proto}://${host}${basePrefix}`;
89
+ }
90
+
91
+ function normalizeIndex(idx) {
92
+ if (!idx || typeof idx !== 'object') return null;
93
+ const fields = idx.fields;
94
+ if (!fields || typeof fields !== 'object') return null;
95
+ const options = idx.options && typeof idx.options === 'object' ? idx.options : {};
96
+ return { fields, options };
97
+ }
98
+
99
+ function normalizeField(field) {
100
+ if (!field || typeof field !== 'object') return null;
101
+ const name = String(field.name || '').trim();
102
+ if (!name) return null;
103
+ const type = String(field.type || '').trim();
104
+ if (!type) return null;
105
+ const normalized = {
106
+ name,
107
+ type,
108
+ required: Boolean(field.required),
109
+ unique: Boolean(field.unique),
110
+ };
111
+ if (field.default !== undefined) normalized.default = field.default;
112
+ if (field.validation && typeof field.validation === 'object') {
113
+ normalized.validation = { ...field.validation };
114
+ }
115
+ if (field.refModelCode !== undefined && field.refModelCode !== null) {
116
+ normalized.refModelCode = String(field.refModelCode || '').trim() || null;
117
+ }
118
+ return normalized;
119
+ }
120
+
121
+ function validateDefinitionShape(definition, { allowedRefModelCodes } = {}) {
122
+ const errors = [];
123
+ const warnings = [];
124
+
125
+ const serverOwnedFields = [
126
+ 'version',
127
+ 'fieldsHash',
128
+ 'previousFields',
129
+ 'previousIndexes',
130
+ 'isActive',
131
+ 'createdAt',
132
+ 'updatedAt',
133
+ ];
134
+
135
+ const raw = definition && typeof definition === 'object' ? definition : {};
136
+ for (const k of serverOwnedFields) {
137
+ if (raw[k] !== undefined) {
138
+ warnings.push(`Ignored server-owned field: ${k}`);
139
+ }
140
+ }
141
+
142
+ const codeIdentifier = String(raw.codeIdentifier || '').trim();
143
+ if (!codeIdentifier) errors.push('codeIdentifier is required');
144
+ if (codeIdentifier && !/^[a-z][a-z0-9_]*$/.test(codeIdentifier)) {
145
+ errors.push('codeIdentifier must match /^[a-z][a-z0-9_]*$/');
146
+ }
147
+
148
+ const displayName = String(raw.displayName || codeIdentifier || '').trim();
149
+ if (!displayName) errors.push('displayName is required');
150
+
151
+ const fieldsIn = Array.isArray(raw.fields) ? raw.fields : [];
152
+ const fields = fieldsIn.map(normalizeField).filter(Boolean);
153
+
154
+ const reserved = new Set(['_id', '_headlessModelCode', '_headlessSchemaVersion']);
155
+ const names = new Set();
156
+ for (const f of fields) {
157
+ if (reserved.has(f.name)) {
158
+ errors.push(`Field name is reserved: ${f.name}`);
159
+ continue;
160
+ }
161
+ if (names.has(f.name)) {
162
+ errors.push(`Duplicate field name: ${f.name}`);
163
+ continue;
164
+ }
165
+ names.add(f.name);
166
+
167
+ const type = String(f.type || '').toLowerCase();
168
+ const isRef = type === 'ref' || type === 'reference';
169
+ const isRefArray = type === 'ref[]' || type === 'ref_array' || type === 'refarray';
170
+
171
+ const supported = new Set([
172
+ 'string',
173
+ 'number',
174
+ 'boolean',
175
+ 'date',
176
+ 'object',
177
+ 'array',
178
+ 'ref',
179
+ 'reference',
180
+ 'ref[]',
181
+ 'ref_array',
182
+ 'refarray',
183
+ ]);
184
+
185
+ if (!supported.has(type)) {
186
+ errors.push(`Unsupported field type: ${f.type}`);
187
+ }
188
+
189
+ if ((isRef || isRefArray) && !String(f.refModelCode || '').trim()) {
190
+ errors.push(`Field ${f.name} is reference type but refModelCode is missing`);
191
+ }
192
+
193
+ if ((isRef || isRefArray) && String(f.refModelCode || '').trim() && allowedRefModelCodes) {
194
+ const refCode = String(f.refModelCode || '').trim();
195
+ if (!allowedRefModelCodes.has(refCode)) {
196
+ warnings.push(`refModelCode does not exist yet: ${refCode}`);
197
+ }
198
+ }
199
+
200
+ if (f.validation && typeof f.validation === 'object') {
201
+ const v = f.validation;
202
+ if (v.minLength !== undefined && !Number.isFinite(Number(v.minLength))) {
203
+ errors.push(`Field ${f.name} validation.minLength must be a number`);
204
+ }
205
+ if (v.maxLength !== undefined && !Number.isFinite(Number(v.maxLength))) {
206
+ errors.push(`Field ${f.name} validation.maxLength must be a number`);
207
+ }
208
+ if (v.minLength !== undefined && v.maxLength !== undefined) {
209
+ const minL = Number(v.minLength);
210
+ const maxL = Number(v.maxLength);
211
+ if (Number.isFinite(minL) && Number.isFinite(maxL) && minL > maxL) {
212
+ errors.push(`Field ${f.name} validation.minLength must be <= maxLength`);
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ const indexesIn = Array.isArray(raw.indexes) ? raw.indexes : [];
219
+ const indexes = indexesIn.map(normalizeIndex).filter(Boolean);
220
+ for (const idx of indexes) {
221
+ if (!idx.fields || typeof idx.fields !== 'object') {
222
+ errors.push('Index fields must be an object');
223
+ }
224
+ }
225
+
226
+ const normalized = {
227
+ codeIdentifier,
228
+ displayName,
229
+ description: String(raw.description || ''),
230
+ fields,
231
+ indexes,
232
+ };
233
+
234
+ return {
235
+ valid: errors.length === 0,
236
+ errors,
237
+ warnings,
238
+ normalized,
239
+ };
240
+ }
241
+
242
+ function applyPatchOpsToModel(definition, ops = []) {
243
+ const next = {
244
+ ...definition,
245
+ fields: Array.isArray(definition.fields) ? [...definition.fields] : [],
246
+ indexes: Array.isArray(definition.indexes) ? [...definition.indexes] : [],
247
+ };
248
+
249
+ const operations = Array.isArray(ops) ? ops : [];
250
+ const errors = [];
251
+ const warnings = [];
252
+
253
+ for (const op of operations) {
254
+ if (!op || typeof op !== 'object') continue;
255
+ const kind = String(op.op || '').trim();
256
+
257
+ if (kind === 'setDisplayName') {
258
+ const value = String(op.value || '').trim();
259
+ if (!value) errors.push('setDisplayName requires non-empty value');
260
+ else next.displayName = value;
261
+ continue;
262
+ }
263
+
264
+ if (kind === 'setDescription') {
265
+ next.description = String(op.value || '');
266
+ continue;
267
+ }
268
+
269
+ if (kind === 'addField') {
270
+ const f = normalizeField(op.field);
271
+ if (!f) {
272
+ errors.push('addField requires a valid field');
273
+ continue;
274
+ }
275
+ const exists = next.fields.some((x) => x && x.name === f.name);
276
+ if (exists) {
277
+ errors.push(`addField duplicate field: ${f.name}`);
278
+ continue;
279
+ }
280
+ next.fields.push(f);
281
+ continue;
282
+ }
283
+
284
+ if (kind === 'removeField') {
285
+ const name = String(op.name || '').trim();
286
+ if (!name) {
287
+ errors.push('removeField requires name');
288
+ continue;
289
+ }
290
+ const before = next.fields.length;
291
+ next.fields = next.fields.filter((x) => x && x.name !== name);
292
+ if (next.fields.length === before) warnings.push(`removeField: field not found: ${name}`);
293
+ continue;
294
+ }
295
+
296
+ if (kind === 'replaceField') {
297
+ const name = String(op.name || '').trim();
298
+ const f = normalizeField(op.field);
299
+ if (!name || !f) {
300
+ errors.push('replaceField requires name and a valid field');
301
+ continue;
302
+ }
303
+ const idx = next.fields.findIndex((x) => x && x.name === name);
304
+ if (idx === -1) {
305
+ errors.push(`replaceField: field not found: ${name}`);
306
+ continue;
307
+ }
308
+ if (f.name !== name) {
309
+ errors.push('replaceField field.name must match op.name (rename not supported)');
310
+ continue;
311
+ }
312
+ next.fields[idx] = f;
313
+ continue;
314
+ }
315
+
316
+ if (kind === 'addIndex') {
317
+ const idx = normalizeIndex(op.index);
318
+ if (!idx) {
319
+ errors.push('addIndex requires valid index');
320
+ continue;
321
+ }
322
+ next.indexes.push(idx);
323
+ continue;
324
+ }
325
+
326
+ if (kind === 'removeIndex') {
327
+ const fields = op.fields;
328
+ if (!fields || typeof fields !== 'object') {
329
+ errors.push('removeIndex requires fields object');
330
+ continue;
331
+ }
332
+ const before = next.indexes.length;
333
+ next.indexes = next.indexes.filter((x) => {
334
+ if (!x || typeof x !== 'object') return false;
335
+ try {
336
+ return JSON.stringify(x.fields) !== JSON.stringify(fields);
337
+ } catch {
338
+ return true;
339
+ }
340
+ });
341
+ if (next.indexes.length === before) warnings.push('removeIndex: index not found');
342
+ continue;
343
+ }
344
+
345
+ warnings.push(`Unknown patch op ignored: ${kind || '(empty op)'}`);
346
+ }
347
+
348
+ return { next, errors, warnings };
349
+ }
350
+
351
+ exports.listModels = async (req, res) => {
352
+ try {
353
+ const items = await listModelDefinitions();
354
+ return res.json({ items });
355
+ } catch (error) {
356
+ console.error('Error listing headless models:', error);
357
+ return handleServiceError(res, error);
358
+ }
359
+ };
360
+
361
+ exports.getModel = async (req, res) => {
362
+ try {
363
+ const item = await getModelDefinitionByCode(req.params.codeIdentifier);
364
+ if (!item) return res.status(404).json({ error: 'Model not found' });
365
+ return res.json({ item });
366
+ } catch (error) {
367
+ console.error('Error fetching headless model:', error);
368
+ return handleServiceError(res, error);
369
+ }
370
+ };
371
+
372
+ exports.createModel = async (req, res) => {
373
+ try {
374
+ const item = await createModelDefinition(req.body || {});
375
+ return res.status(201).json({ item });
376
+ } catch (error) {
377
+ console.error('Error creating headless model:', error);
378
+ return handleServiceError(res, error);
379
+ }
380
+ };
381
+
382
+ exports.updateModel = async (req, res) => {
383
+ try {
384
+ const item = await updateModelDefinition(req.params.codeIdentifier, req.body || {});
385
+ return res.json({ item });
386
+ } catch (error) {
387
+ console.error('Error updating headless model:', error);
388
+ return handleServiceError(res, error);
389
+ }
390
+ };
391
+
392
+ exports.deleteModel = async (req, res) => {
393
+ try {
394
+ const item = await disableModelDefinition(req.params.codeIdentifier);
395
+ return res.json({ item });
396
+ } catch (error) {
397
+ console.error('Error deleting headless model:', error);
398
+ return handleServiceError(res, error);
399
+ }
400
+ };
401
+
402
+ exports.validateModelDefinition = async (req, res) => {
403
+ try {
404
+ const body = req.body || {};
405
+ const definition = body.definition;
406
+
407
+ const existing = await listModelDefinitions();
408
+ const allowedRefModelCodes = new Set((existing || []).map((m) => m.codeIdentifier));
409
+
410
+ const result = validateDefinitionShape(definition, { allowedRefModelCodes });
411
+ return res.json(result);
412
+ } catch (error) {
413
+ console.error('Error validating headless model definition:', error);
414
+ const mapped = toSafeJsonError(error);
415
+ return res.status(mapped.status).json(mapped.body);
416
+ }
417
+ };
418
+
419
+ exports.applyModelProposal = async (req, res) => {
420
+ try {
421
+ const body = req.body || {};
422
+ const creates = Array.isArray(body.creates) ? body.creates : [];
423
+ const updates = Array.isArray(body.updates) ? body.updates : [];
424
+
425
+ const existing = await listModelDefinitions();
426
+ const allowedRefModelCodes = new Set((existing || []).map((m) => m.codeIdentifier));
427
+ for (const c of creates) {
428
+ const code = String(c?.codeIdentifier || '').trim();
429
+ if (code) allowedRefModelCodes.add(code);
430
+ }
431
+
432
+ const results = {
433
+ created: [],
434
+ updated: [],
435
+ errors: [],
436
+ warnings: [],
437
+ };
438
+
439
+ for (const def of creates) {
440
+ const v = validateDefinitionShape(def, { allowedRefModelCodes });
441
+ results.warnings.push(...(v.warnings || []).map((w) => `[create:${v.normalized?.codeIdentifier || '?'}] ${w}`));
442
+ if (!v.valid) {
443
+ results.errors.push({
444
+ op: 'create',
445
+ codeIdentifier: v.normalized?.codeIdentifier || null,
446
+ errors: v.errors,
447
+ });
448
+ continue;
449
+ }
450
+ try {
451
+ const created = await createModelDefinition(v.normalized);
452
+ results.created.push(created);
453
+ } catch (e) {
454
+ results.errors.push({
455
+ op: 'create',
456
+ codeIdentifier: v.normalized?.codeIdentifier || null,
457
+ error: e.message,
458
+ });
459
+ }
460
+ }
461
+
462
+ for (const up of updates) {
463
+ const codeIdentifier = String(up?.codeIdentifier || '').trim();
464
+ if (!codeIdentifier) {
465
+ results.errors.push({ op: 'update', codeIdentifier: null, error: 'codeIdentifier is required' });
466
+ continue;
467
+ }
468
+ let current;
469
+ try {
470
+ current = await getModelDefinitionByCode(codeIdentifier);
471
+ } catch (e) {
472
+ results.errors.push({ op: 'update', codeIdentifier, error: e.message });
473
+ continue;
474
+ }
475
+ if (!current) {
476
+ results.errors.push({ op: 'update', codeIdentifier, error: 'Model not found' });
477
+ continue;
478
+ }
479
+
480
+ const { next, errors, warnings } = applyPatchOpsToModel(current, up.ops);
481
+ results.warnings.push(...(warnings || []).map((w) => `[update:${codeIdentifier}] ${w}`));
482
+ if (errors && errors.length) {
483
+ results.errors.push({ op: 'update', codeIdentifier, errors });
484
+ continue;
485
+ }
486
+
487
+ const v = validateDefinitionShape({ ...next, codeIdentifier }, { allowedRefModelCodes });
488
+ results.warnings.push(...(v.warnings || []).map((w) => `[update:${codeIdentifier}] ${w}`));
489
+ if (!v.valid) {
490
+ results.errors.push({ op: 'update', codeIdentifier, errors: v.errors });
491
+ continue;
492
+ }
493
+
494
+ try {
495
+ const updated = await updateModelDefinition(codeIdentifier, {
496
+ displayName: v.normalized.displayName,
497
+ description: v.normalized.description,
498
+ fields: v.normalized.fields,
499
+ indexes: v.normalized.indexes,
500
+ });
501
+ results.updated.push(updated);
502
+ } catch (e) {
503
+ results.errors.push({ op: 'update', codeIdentifier, error: e.message });
504
+ }
505
+ }
506
+
507
+ return res.json(results);
508
+ } catch (error) {
509
+ console.error('Error applying headless model proposal:', error);
510
+ const mapped = toSafeJsonError(error);
511
+ return res.status(mapped.status).json(mapped.body);
512
+ }
513
+ };
514
+
515
+ exports.aiModelBuilderChat = async (req, res) => {
516
+ try {
517
+ const body = req.body || {};
518
+ const message = String(body.message || '').trim();
519
+ const history = Array.isArray(body.history) ? body.history : [];
520
+ const currentDefinition = body.currentDefinition && typeof body.currentDefinition === 'object'
521
+ ? body.currentDefinition
522
+ : null;
523
+
524
+ if (!message) {
525
+ return res.status(400).json({ error: 'message is required' });
526
+ }
527
+
528
+ const existing = await listModelDefinitions();
529
+ const allowedRefModelCodes = new Set((existing || []).map((m) => m.codeIdentifier));
530
+
531
+ const cheatSheet = [
532
+ 'You are helping define Headless CMS models for a Mongo-backed dynamic schema system.',
533
+ 'Return STRICT JSON only (no markdown, no prose outside JSON).',
534
+ '',
535
+ 'RESPONSE FORMAT (required):',
536
+ '{',
537
+ ' "assistantMessage": "Brief explanation of what you changed and why",',
538
+ ' "proposal": {',
539
+ ' "creates": [<modelDef>],',
540
+ ' "updates": [{ codeIdentifier, ops: [<patchOp>] }]',
541
+ ' },',
542
+ ' "questions": [],',
543
+ ' "warnings": []',
544
+ '}',
545
+ '',
546
+ 'EXAMPLE RESPONSE:',
547
+ '{',
548
+ ' "assistantMessage": "Added age field as a number with optional validation",',
549
+ ' "proposal": {',
550
+ ' "creates": [],',
551
+ ' "updates": [',
552
+ ' {',
553
+ ' "codeIdentifier": "products",',
554
+ ' "ops": [',
555
+ ' {',
556
+ ' "op": "addField",',
557
+ ' "field": {',
558
+ ' "name": "age",',
559
+ ' "type": "number",',
560
+ ' "required": false',
561
+ ' "validation": { "min": 0, "max": 150 }',
562
+ ' }',
563
+ ' }',
564
+ ' ]',
565
+ ' }',
566
+ ' ]',
567
+ ' },',
568
+ ' "questions": [],',
569
+ ' "warnings": []',
570
+ '}',
571
+ '',
572
+ 'Model definition shape:',
573
+ '{ codeIdentifier, displayName, description?, fields: [], indexes: [] }',
574
+ '',
575
+ 'Field shape:',
576
+ '{ name, type, required?, unique?, default?, validation?, refModelCode? }',
577
+ '',
578
+ 'Supported field types:',
579
+ '- string, number, boolean, date, object, array',
580
+ '- ref (requires refModelCode)',
581
+ '- ref[] (requires refModelCode)',
582
+ '',
583
+ 'Supported string validation keys:',
584
+ '- minLength, maxLength, enum, match',
585
+ '',
586
+ 'Supported number validation keys:',
587
+ '- min, max',
588
+ '',
589
+ 'Model-level indexes:',
590
+ 'indexes: [{ fields: { fieldName: 1, other: -1 }, options: { unique?: true } }]',
591
+ '',
592
+ 'Patch ops supported:',
593
+ '- { op: "setDisplayName", value: string }',
594
+ '- { op: "setDescription", value: string }',
595
+ '- { op: "addField", field: <field> }',
596
+ '- { op: "removeField", name: string }',
597
+ '- { op: "replaceField", name: string, field: <field with same name> }',
598
+ '- { op: "addIndex", index: <index> }',
599
+ '- { op: "removeIndex", fields: <index fields object> }',
600
+ '',
601
+ 'Do not include server-owned fields like version/fieldsHash/previousFields.',
602
+ 'Prefer minimal diffs: if currentDefinition exists, use updates instead of creating from scratch.',
603
+ ].join('\n');
604
+
605
+ const context = currentDefinition
606
+ ? `Current model JSON (may be partial):\n${JSON.stringify(currentDefinition, null, 2)}`
607
+ : 'Current model JSON: (none)';
608
+
609
+ const messages = [
610
+ { role: 'user', content: cheatSheet },
611
+ { role: 'user', content: context },
612
+ ...history.map((m) => ({
613
+ role: m.role === 'assistant' ? 'assistant' : 'user',
614
+ content: String(m.content || ''),
615
+ })),
616
+ { role: 'user', content: message },
617
+ ];
618
+
619
+ const providerKey = (await getSettingValue('headless.aiProviderKey')) || process.env.HEADLESS_AI_PROVIDER_KEY || 'openrouter';
620
+ const model = (await getSettingValue('headless.aiModel')) || process.env.HEADLESS_AI_MODEL || 'google/gemini-2.5-flash-lite';
621
+
622
+ console.log('[headless aiModelBuilder] Resolved providerKey:', providerKey);
623
+ console.log('[headless aiModelBuilder] Resolved model:', model);
624
+
625
+ const llm = await llmService.callAdhoc(
626
+ { providerKey, model, messages, promptKeyForAudit: 'headless.aiModelBuilder' },
627
+ { temperature: 0.2 },
628
+ );
629
+
630
+ let parsed;
631
+ const rawResponse = String(llm.content || '').trim();
632
+ console.log('[headless aiModelBuilder] Raw LLM response:', rawResponse);
633
+
634
+ // Audit the interaction
635
+ const actor = getBasicAuthActor(req);
636
+ await createAuditEvent({
637
+ ...actor,
638
+ action: 'headless.aiModelBuilder.chat',
639
+ entityType: 'HeadlessModelDefinition',
640
+ metadata: {
641
+ providerKey,
642
+ model,
643
+ message,
644
+ rawResponse,
645
+ responseLength: rawResponse.length,
646
+ },
647
+ });
648
+
649
+ try {
650
+ parsed = JSON.parse(rawResponse);
651
+ } catch (e) {
652
+ console.log('[headless aiModelBuilder] Direct JSON parse failed, attempting markdown extraction:', e.message);
653
+
654
+ // Try to extract JSON from markdown code blocks
655
+ const jsonMatch = rawResponse.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
656
+ const extracted = jsonMatch ? jsonMatch[1].trim() : rawResponse;
657
+
658
+ try {
659
+ parsed = JSON.parse(extracted);
660
+ console.log('[headless aiModelBuilder] Successfully parsed JSON from markdown block');
661
+ } catch (e2) {
662
+ console.error('[headless aiModelBuilder] JSON extraction also failed:', e2.message);
663
+ console.error('[headless aiModelBuilder] Attempted to parse:', extracted);
664
+ return res.status(502).json({
665
+ error: 'LLM did not return valid JSON',
666
+ details: e.message,
667
+ rawResponse: rawResponse.substring(0, 500) + (rawResponse.length > 500 ? '...' : '')
668
+ });
669
+ }
670
+ }
671
+
672
+ const assistantMessage = String(parsed.assistantMessage || '').trim();
673
+ const proposal = parsed.proposal && typeof parsed.proposal === 'object' ? parsed.proposal : null;
674
+ const questions = Array.isArray(parsed.questions) ? parsed.questions : [];
675
+ const warnings = Array.isArray(parsed.warnings) ? parsed.warnings : [];
676
+
677
+ if (!proposal) {
678
+ return res.status(400).json({ error: 'LLM response missing proposal' });
679
+ }
680
+
681
+ const creates = Array.isArray(proposal.creates) ? proposal.creates : [];
682
+ const updates = Array.isArray(proposal.updates) ? proposal.updates : [];
683
+
684
+ for (const c of creates) {
685
+ const code = String(c?.codeIdentifier || '').trim();
686
+ if (code) allowedRefModelCodes.add(code);
687
+ }
688
+
689
+ const validation = { valid: true, errors: [], warnings: [] };
690
+ for (const def of creates) {
691
+ const v = validateDefinitionShape(def, { allowedRefModelCodes });
692
+ validation.warnings.push(...(v.warnings || []).map((w) => `[create:${v.normalized?.codeIdentifier || '?'}] ${w}`));
693
+ if (!v.valid) {
694
+ validation.valid = false;
695
+ validation.errors.push({ op: 'create', codeIdentifier: v.normalized?.codeIdentifier || null, errors: v.errors });
696
+ }
697
+ }
698
+
699
+ for (const up of updates) {
700
+ const codeIdentifier = String(up?.codeIdentifier || '').trim();
701
+ if (!codeIdentifier) {
702
+ validation.valid = false;
703
+ validation.errors.push({ op: 'update', codeIdentifier: null, error: 'codeIdentifier is required' });
704
+ continue;
705
+ }
706
+ const current = await getModelDefinitionByCode(codeIdentifier);
707
+ if (!current) {
708
+ validation.valid = false;
709
+ validation.errors.push({ op: 'update', codeIdentifier, error: 'Model not found' });
710
+ continue;
711
+ }
712
+ const { next, errors, warnings: patchWarnings } = applyPatchOpsToModel(current, up.ops);
713
+ validation.warnings.push(...(patchWarnings || []).map((w) => `[update:${codeIdentifier}] ${w}`));
714
+ if (errors && errors.length) {
715
+ validation.valid = false;
716
+ validation.errors.push({ op: 'update', codeIdentifier, errors });
717
+ continue;
718
+ }
719
+ const v = validateDefinitionShape({ ...next, codeIdentifier }, { allowedRefModelCodes });
720
+ validation.warnings.push(...(v.warnings || []).map((w) => `[update:${codeIdentifier}] ${w}`));
721
+ if (!v.valid) {
722
+ validation.valid = false;
723
+ validation.errors.push({ op: 'update', codeIdentifier, errors: v.errors });
724
+ }
725
+ }
726
+
727
+ return res.json({
728
+ assistantMessage,
729
+ proposal: { creates, updates },
730
+ questions,
731
+ warnings,
732
+ validation,
733
+ });
734
+ } catch (error) {
735
+ console.error('Error in AI model builder chat:', error);
736
+ const mapped = toSafeJsonError(error);
737
+ return res.status(mapped.status).json(mapped.body);
738
+ }
739
+ };
740
+
741
+ // Admin collections CRUD (bypass API tokens)
742
+ exports.listCollectionItems = async (req, res) => {
743
+ try {
744
+ const { modelCode } = req.params;
745
+ const Model = await getDynamicModel(modelCode);
746
+
747
+ const limit = Math.min(Number(req.query.limit || 50) || 50, 200);
748
+ const skip = Number(req.query.skip || 0) || 0;
749
+
750
+ let filter = {};
751
+ let sort = { updatedAt: -1 };
752
+
753
+ if (req.query.filter) {
754
+ try {
755
+ filter = JSON.parse(String(req.query.filter));
756
+ } catch {
757
+ return res.status(400).json({ error: 'Invalid filter JSON' });
758
+ }
759
+ }
760
+
761
+ if (req.query.sort) {
762
+ try {
763
+ sort = JSON.parse(String(req.query.sort));
764
+ } catch {
765
+ return res.status(400).json({ error: 'Invalid sort JSON' });
766
+ }
767
+ }
768
+
769
+ const items = await Model.find(filter).sort(sort).skip(skip).limit(limit).lean();
770
+ const total = await Model.countDocuments(filter);
771
+
772
+ return res.json({ items, total, limit, skip });
773
+ } catch (error) {
774
+ console.error('Error listing headless collection items:', error);
775
+ return handleServiceError(res, error);
776
+ }
777
+ };
778
+
779
+ exports.createCollectionItem = async (req, res) => {
780
+ try {
781
+ const { modelCode } = req.params;
782
+ const Model = await getDynamicModel(modelCode);
783
+
784
+ const modelDef = await getModelDefinitionByCode(modelCode);
785
+ if (!modelDef) {
786
+ return res.status(404).json({ error: 'Model not found' });
787
+ }
788
+
789
+ const payload = { ...req.body };
790
+ for (const field of modelDef.fields || []) {
791
+ if (field.required && payload[field.name] === undefined) {
792
+ if (field.default !== undefined) {
793
+ payload[field.name] = field.default;
794
+ } else if (field.type === 'boolean') {
795
+ payload[field.name] = false;
796
+ } else if (field.type === 'number') {
797
+ payload[field.name] = 0;
798
+ } else if (field.type === 'date') {
799
+ payload[field.name] = new Date();
800
+ } else {
801
+ payload[field.name] = '';
802
+ }
803
+ }
804
+ }
805
+
806
+ const doc = await Model.create(payload);
807
+ return res.status(201).json({ item: doc.toObject() });
808
+ } catch (error) {
809
+ console.error('Error creating headless collection item:', error);
810
+ return handleServiceError(res, error);
811
+ }
812
+ };
813
+
814
+ exports.updateCollectionItem = async (req, res) => {
815
+ try {
816
+ const { modelCode, id } = req.params;
817
+ const Model = await getDynamicModel(modelCode);
818
+
819
+ const updated = await Model.findByIdAndUpdate(id, req.body || {}, {
820
+ new: true,
821
+ runValidators: false,
822
+ });
823
+
824
+ if (!updated) return res.status(404).json({ error: 'Item not found' });
825
+ return res.json({ item: updated.toObject() });
826
+ } catch (error) {
827
+ console.error('Error updating headless collection item:', error);
828
+ return handleServiceError(res, error);
829
+ }
830
+ };
831
+
832
+ exports.deleteCollectionItem = async (req, res) => {
833
+ try {
834
+ const { modelCode, id } = req.params;
835
+ const Model = await getDynamicModel(modelCode);
836
+
837
+ const deleted = await Model.findByIdAndDelete(id);
838
+ if (!deleted) return res.status(404).json({ error: 'Item not found' });
839
+
840
+ return res.json({ success: true });
841
+ } catch (error) {
842
+ console.error('Error deleting headless collection item:', error);
843
+ return handleServiceError(res, error);
844
+ }
845
+ };
846
+
847
+ exports.executeCollectionsApiTest = async (req, res) => {
848
+ const startedAt = Date.now();
849
+ const MAX_META_BYTES = 10 * 1024;
850
+
851
+ const actor = getBasicAuthActor(req);
852
+ const payload = req.body && typeof req.body === 'object' ? req.body : {};
853
+
854
+ const op = String(payload.op || '').trim();
855
+ const modelCode = String(payload.modelCode || '').trim();
856
+ const tokenType = String(payload?.auth?.type || 'bearer').trim();
857
+ const token = String(payload?.auth?.token || '').trim();
858
+ const pathVars = payload.pathVars && typeof payload.pathVars === 'object' ? payload.pathVars : {};
859
+ const query = payload.query && typeof payload.query === 'object' ? payload.query : {};
860
+ const body = payload.body && typeof payload.body === 'object' ? payload.body : undefined;
861
+
862
+ if (!['list', 'create', 'update', 'delete'].includes(op)) {
863
+ return res.status(400).json({ error: 'Invalid op' });
864
+ }
865
+ if (!modelCode || !/^[a-z][a-z0-9_]*$/.test(modelCode)) {
866
+ return res.status(400).json({ error: 'Invalid modelCode' });
867
+ }
868
+ if (!token) {
869
+ return res.status(400).json({ error: 'Missing API token' });
870
+ }
871
+
872
+ const id = String(pathVars.id || '').trim();
873
+ if ((op === 'update' || op === 'delete') && !id) {
874
+ return res.status(400).json({ error: 'Missing id' });
875
+ }
876
+
877
+ let method;
878
+ let path;
879
+ if (op === 'list') {
880
+ method = 'GET';
881
+ path = `/api/headless/${encodeURIComponent(modelCode)}`;
882
+ } else if (op === 'create') {
883
+ method = 'POST';
884
+ path = `/api/headless/${encodeURIComponent(modelCode)}`;
885
+ } else if (op === 'update') {
886
+ method = 'PUT';
887
+ path = `/api/headless/${encodeURIComponent(modelCode)}/${encodeURIComponent(id)}`;
888
+ } else {
889
+ method = 'DELETE';
890
+ path = `/api/headless/${encodeURIComponent(modelCode)}/${encodeURIComponent(id)}`;
891
+ }
892
+
893
+ const params = {};
894
+ if (query.limit !== undefined && query.limit !== null && query.limit !== '') params.limit = Number(query.limit);
895
+ if (query.skip !== undefined && query.skip !== null && query.skip !== '') params.skip = Number(query.skip);
896
+ if (query.populate) params.populate = String(query.populate);
897
+
898
+ if (query.filter && typeof query.filter === 'object') {
899
+ params.filter = JSON.stringify(query.filter);
900
+ } else if (typeof query.filter === 'string' && query.filter.trim()) {
901
+ const parsed = safeJsonParse(query.filter);
902
+ if (parsed && typeof parsed === 'object') params.filter = JSON.stringify(parsed);
903
+ }
904
+
905
+ if (query.sort && typeof query.sort === 'object') {
906
+ params.sort = JSON.stringify(query.sort);
907
+ } else if (typeof query.sort === 'string' && query.sort.trim()) {
908
+ const parsed = safeJsonParse(query.sort);
909
+ if (parsed && typeof parsed === 'object') params.sort = JSON.stringify(parsed);
910
+ }
911
+
912
+ const headers = {};
913
+ if (tokenType === 'x-api-token') headers['X-API-Token'] = token;
914
+ else if (tokenType === 'x-api-key') headers['X-API-Key'] = token;
915
+ else headers.Authorization = `Bearer ${token}`;
916
+
917
+ let outcome = 'success';
918
+ let responseStatus = 0;
919
+ let responseHeaders = {};
920
+ let responseBody = null;
921
+
922
+ try {
923
+ const base = buildLoopbackBaseUrl(req);
924
+ const url = `${base}${path}`;
925
+
926
+ const axiosRes = await axios.request({
927
+ url,
928
+ method,
929
+ headers,
930
+ params,
931
+ data: op === 'create' || op === 'update' ? (body || {}) : undefined,
932
+ timeout: 15000,
933
+ validateStatus: () => true,
934
+ });
935
+
936
+ responseStatus = axiosRes.status;
937
+ responseHeaders = axiosRes.headers || {};
938
+ responseBody = axiosRes.data;
939
+ if (responseStatus >= 400) outcome = 'failure';
940
+
941
+ const durationMs = Date.now() - startedAt;
942
+ const sanitized = sanitizeAndTruncateMeta(responseBody, MAX_META_BYTES);
943
+
944
+ await logAudit({
945
+ req,
946
+ actor,
947
+ action: 'headless.collections_api_test',
948
+ entityType: 'headless_collection',
949
+ entityId: modelCode,
950
+ targetType: 'headless_collection',
951
+ targetId: id || modelCode,
952
+ outcome,
953
+ meta: {
954
+ op,
955
+ modelCode,
956
+ request: {
957
+ method,
958
+ path,
959
+ query: scrubObject(query),
960
+ hasBody: Boolean(op === 'create' || op === 'update'),
961
+ },
962
+ response: {
963
+ status: responseStatus,
964
+ durationMs,
965
+ headers: {
966
+ 'content-type': responseHeaders['content-type'],
967
+ 'content-length': responseHeaders['content-length'],
968
+ 'x-request-id': responseHeaders['x-request-id'],
969
+ },
970
+ body: sanitized.value,
971
+ bodyTruncated: sanitized.truncated,
972
+ },
973
+ },
974
+ });
975
+
976
+ return res.status(200).json({
977
+ ok: responseStatus < 400,
978
+ status: responseStatus,
979
+ durationMs,
980
+ headers: {
981
+ 'content-type': responseHeaders['content-type'],
982
+ 'content-length': responseHeaders['content-length'],
983
+ 'x-request-id': responseHeaders['x-request-id'],
984
+ },
985
+ body: responseBody,
986
+ bodyTruncated: sanitized.truncated,
987
+ });
988
+ } catch (error) {
989
+ outcome = 'failure';
990
+ const durationMs = Date.now() - startedAt;
991
+
992
+ await logAudit({
993
+ req,
994
+ actor,
995
+ action: 'headless.collections_api_test',
996
+ entityType: 'headless_collection',
997
+ entityId: modelCode,
998
+ targetType: 'headless_collection',
999
+ targetId: id || modelCode,
1000
+ outcome,
1001
+ meta: {
1002
+ op,
1003
+ modelCode,
1004
+ request: {
1005
+ method,
1006
+ path,
1007
+ query: scrubObject(query),
1008
+ hasBody: Boolean(op === 'create' || op === 'update'),
1009
+ },
1010
+ error: {
1011
+ message: String(error?.message || 'Request failed'),
1012
+ },
1013
+ durationMs,
1014
+ },
1015
+ });
1016
+
1017
+ return res.status(502).json({ error: error?.message || 'Request failed' });
1018
+ }
1019
+ };
1020
+
1021
+ // API tokens
1022
+ exports.listTokens = async (req, res) => {
1023
+ try {
1024
+ const items = await listApiTokens();
1025
+ return res.json({ items });
1026
+ } catch (error) {
1027
+ console.error('Error listing headless API tokens:', error);
1028
+ return handleServiceError(res, error);
1029
+ }
1030
+ };
1031
+
1032
+ exports.getToken = async (req, res) => {
1033
+ try {
1034
+ const item = await getApiTokenById(req.params.id);
1035
+ if (!item) return res.status(404).json({ error: 'API token not found' });
1036
+ return res.json({ item });
1037
+ } catch (error) {
1038
+ console.error('Error fetching headless API token:', error);
1039
+ return handleServiceError(res, error);
1040
+ }
1041
+ };
1042
+
1043
+ exports.createToken = async (req, res) => {
1044
+ try {
1045
+ const { token, item } = await createApiToken(req.body || {});
1046
+ return res.status(201).json({ token, item });
1047
+ } catch (error) {
1048
+ console.error('Error creating headless API token:', error);
1049
+ return handleServiceError(res, error);
1050
+ }
1051
+ };
1052
+
1053
+ exports.updateToken = async (req, res) => {
1054
+ try {
1055
+ const item = await updateApiToken(req.params.id, req.body || {});
1056
+ return res.json({ item });
1057
+ } catch (error) {
1058
+ console.error('Error updating headless API token:', error);
1059
+ return handleServiceError(res, error);
1060
+ }
1061
+ };
1062
+
1063
+ exports.deleteToken = async (req, res) => {
1064
+ try {
1065
+ const result = await deleteApiToken(req.params.id);
1066
+ return res.json(result);
1067
+ } catch (error) {
1068
+ console.error('Error deleting headless API token:', error);
1069
+ return handleServiceError(res, error);
1070
+ }
1071
+ };