@intranefr/superbackend 1.5.0 → 1.5.2

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 (198) hide show
  1. package/.env.example +15 -0
  2. package/README.md +11 -0
  3. package/analysis-only.skill +0 -0
  4. package/index.js +23 -0
  5. package/package.json +8 -2
  6. package/src/admin/endpointRegistry.js +120 -0
  7. package/src/controllers/admin.controller.js +90 -6
  8. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  9. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  10. package/src/controllers/adminCache.controller.js +342 -0
  11. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  12. package/src/controllers/adminCrons.controller.js +388 -0
  13. package/src/controllers/adminDbBrowser.controller.js +124 -0
  14. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  15. package/src/controllers/adminExperiments.controller.js +200 -0
  16. package/src/controllers/adminHeadless.controller.js +9 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +126 -4
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/blogAdmin.controller.js +279 -0
  28. package/src/controllers/blogAiAdmin.controller.js +224 -0
  29. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  30. package/src/controllers/blogInternal.controller.js +26 -0
  31. package/src/controllers/blogPublic.controller.js +89 -0
  32. package/src/controllers/experiments.controller.js +85 -0
  33. package/src/controllers/fileManager.controller.js +190 -0
  34. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  35. package/src/controllers/healthChecksPublic.controller.js +196 -0
  36. package/src/controllers/internalExperiments.controller.js +17 -0
  37. package/src/controllers/metrics.controller.js +64 -4
  38. package/src/controllers/orgAdmin.controller.js +80 -0
  39. package/src/helpers/mongooseHelper.js +258 -0
  40. package/src/helpers/scriptBase.js +230 -0
  41. package/src/helpers/scriptRunner.js +335 -0
  42. package/src/middleware/rbac.js +62 -0
  43. package/src/middleware.js +810 -48
  44. package/src/models/BlockDefinition.js +27 -0
  45. package/src/models/BlogAutomationLock.js +14 -0
  46. package/src/models/BlogAutomationRun.js +39 -0
  47. package/src/models/BlogPost.js +42 -0
  48. package/src/models/CacheEntry.js +26 -0
  49. package/src/models/ConsoleEntry.js +32 -0
  50. package/src/models/ConsoleLog.js +23 -0
  51. package/src/models/ContextBlockDefinition.js +33 -0
  52. package/src/models/CronExecution.js +47 -0
  53. package/src/models/CronJob.js +70 -0
  54. package/src/models/Experiment.js +75 -0
  55. package/src/models/ExperimentAssignment.js +23 -0
  56. package/src/models/ExperimentEvent.js +26 -0
  57. package/src/models/ExperimentMetricBucket.js +30 -0
  58. package/src/models/ExternalDbConnection.js +49 -0
  59. package/src/models/FileEntry.js +22 -0
  60. package/src/models/GlobalSetting.js +1 -2
  61. package/src/models/HealthAutoHealAttempt.js +57 -0
  62. package/src/models/HealthCheck.js +132 -0
  63. package/src/models/HealthCheckRun.js +51 -0
  64. package/src/models/HealthIncident.js +49 -0
  65. package/src/models/Page.js +95 -0
  66. package/src/models/PageCollection.js +42 -0
  67. package/src/models/ProxyEntry.js +66 -0
  68. package/src/models/RateLimitCounter.js +19 -0
  69. package/src/models/RateLimitMetricBucket.js +20 -0
  70. package/src/models/RbacGrant.js +25 -0
  71. package/src/models/RbacGroup.js +16 -0
  72. package/src/models/RbacGroupMember.js +13 -0
  73. package/src/models/RbacGroupRole.js +13 -0
  74. package/src/models/RbacRole.js +25 -0
  75. package/src/models/RbacUserRole.js +13 -0
  76. package/src/models/ScriptDefinition.js +1 -0
  77. package/src/models/Webhook.js +2 -0
  78. package/src/routes/admin.routes.js +2 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminExperiments.routes.js +29 -0
  88. package/src/routes/adminHeadless.routes.js +2 -1
  89. package/src/routes/adminHealthChecks.routes.js +28 -0
  90. package/src/routes/adminI18n.routes.js +4 -3
  91. package/src/routes/adminLlm.routes.js +4 -2
  92. package/src/routes/adminPages.routes.js +55 -0
  93. package/src/routes/adminProxy.routes.js +15 -0
  94. package/src/routes/adminRateLimits.routes.js +17 -0
  95. package/src/routes/adminRbac.routes.js +38 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminUiComponents.routes.js +2 -1
  98. package/src/routes/blogInternal.routes.js +14 -0
  99. package/src/routes/blogPublic.routes.js +9 -0
  100. package/src/routes/experiments.routes.js +30 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/internalExperiments.routes.js +15 -0
  105. package/src/routes/log.routes.js +43 -60
  106. package/src/routes/metrics.routes.js +4 -2
  107. package/src/routes/orgAdmin.routes.js +1 -0
  108. package/src/routes/pages.routes.js +123 -0
  109. package/src/routes/proxy.routes.js +46 -0
  110. package/src/routes/rbac.routes.js +47 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +185 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +738 -0
  120. package/src/services/consoleOverride.service.js +7 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/experiments.service.js +273 -0
  125. package/src/services/experimentsAggregation.service.js +308 -0
  126. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  127. package/src/services/experimentsRetention.service.js +43 -0
  128. package/src/services/experimentsWs.service.js +134 -0
  129. package/src/services/fileManager.service.js +475 -0
  130. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  131. package/src/services/globalSettings.service.js +15 -0
  132. package/src/services/healthChecks.service.js +650 -0
  133. package/src/services/healthChecksBootstrap.service.js +109 -0
  134. package/src/services/healthChecksScheduler.service.js +106 -0
  135. package/src/services/jsonConfigs.service.js +2 -2
  136. package/src/services/llmDefaults.service.js +190 -0
  137. package/src/services/migrationAssets/s3.js +2 -2
  138. package/src/services/pages.service.js +602 -0
  139. package/src/services/pagesContext.service.js +331 -0
  140. package/src/services/pagesContextBlocksAi.service.js +349 -0
  141. package/src/services/proxy.service.js +535 -0
  142. package/src/services/rateLimiter.service.js +623 -0
  143. package/src/services/rbac.service.js +212 -0
  144. package/src/services/scriptsRunner.service.js +215 -15
  145. package/src/services/uiComponentsAi.service.js +6 -19
  146. package/src/services/workflow.service.js +23 -8
  147. package/src/utils/orgRoles.js +14 -0
  148. package/src/utils/rbac/engine.js +60 -0
  149. package/src/utils/rbac/rightsRegistry.js +33 -0
  150. package/views/admin-blog-automation.ejs +877 -0
  151. package/views/admin-blog-edit.ejs +542 -0
  152. package/views/admin-blog.ejs +399 -0
  153. package/views/admin-cache.ejs +681 -0
  154. package/views/admin-console-manager.ejs +680 -0
  155. package/views/admin-crons.ejs +645 -0
  156. package/views/admin-dashboard.ejs +28 -8
  157. package/views/admin-db-browser.ejs +445 -0
  158. package/views/admin-ejs-virtual.ejs +16 -10
  159. package/views/admin-experiments.ejs +91 -0
  160. package/views/admin-file-manager.ejs +942 -0
  161. package/views/admin-health-checks.ejs +725 -0
  162. package/views/admin-i18n.ejs +59 -5
  163. package/views/admin-llm.ejs +99 -1
  164. package/views/admin-organizations.ejs +163 -1
  165. package/views/admin-pages.ejs +2424 -0
  166. package/views/admin-proxy.ejs +491 -0
  167. package/views/admin-rate-limiter.ejs +625 -0
  168. package/views/admin-rbac.ejs +1331 -0
  169. package/views/admin-scripts.ejs +597 -3
  170. package/views/admin-seo-config.ejs +61 -7
  171. package/views/admin-ui-components.ejs +57 -25
  172. package/views/admin-workflows.ejs +7 -7
  173. package/views/file-manager.ejs +866 -0
  174. package/views/pages/blocks/contact.ejs +27 -0
  175. package/views/pages/blocks/cta.ejs +18 -0
  176. package/views/pages/blocks/faq.ejs +20 -0
  177. package/views/pages/blocks/features.ejs +19 -0
  178. package/views/pages/blocks/hero.ejs +13 -0
  179. package/views/pages/blocks/html.ejs +5 -0
  180. package/views/pages/blocks/image.ejs +14 -0
  181. package/views/pages/blocks/testimonials.ejs +26 -0
  182. package/views/pages/blocks/text.ejs +10 -0
  183. package/views/pages/layouts/default.ejs +51 -0
  184. package/views/pages/layouts/minimal.ejs +42 -0
  185. package/views/pages/layouts/sidebar.ejs +54 -0
  186. package/views/pages/partials/footer.ejs +13 -0
  187. package/views/pages/partials/header.ejs +12 -0
  188. package/views/pages/partials/sidebar.ejs +8 -0
  189. package/views/pages/runtime/page.ejs +10 -0
  190. package/views/pages/templates/article.ejs +20 -0
  191. package/views/pages/templates/default.ejs +12 -0
  192. package/views/pages/templates/landing.ejs +14 -0
  193. package/views/pages/templates/listing.ejs +15 -0
  194. package/views/partials/admin-image-upload-modal.ejs +221 -0
  195. package/views/partials/dashboard/nav-items.ejs +12 -0
  196. package/views/partials/dashboard/palette.ejs +5 -3
  197. package/views/partials/llm-provider-model-picker.ejs +183 -0
  198. package/src/routes/llmUi.routes.js +0 -26
@@ -0,0 +1,475 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const Asset = require('../models/Asset');
4
+ const FileEntry = require('../models/FileEntry');
5
+ const RbacGroup = require('../models/RbacGroup');
6
+ const RbacGroupMember = require('../models/RbacGroupMember');
7
+
8
+ const objectStorage = require('./objectStorage.service');
9
+ const uploadNamespacesService = require('./uploadNamespaces.service');
10
+ const fileManagerStoragePolicyService = require('./fileManagerStoragePolicy.service');
11
+
12
+ function normalizeObjectId(id, name) {
13
+ const str = String(id || '');
14
+ if (!mongoose.Types.ObjectId.isValid(str)) {
15
+ const err = new Error(`${name} must be a valid ObjectId`);
16
+ err.code = 'VALIDATION';
17
+ throw err;
18
+ }
19
+ return new mongoose.Types.ObjectId(str);
20
+ }
21
+
22
+ function normalizeDriveType(value) {
23
+ const t = String(value || '').trim();
24
+ if (t === 'user' || t === 'group' || t === 'org') return t;
25
+ const err = new Error('driveType must be one of: user, group, org');
26
+ err.code = 'VALIDATION';
27
+ throw err;
28
+ }
29
+
30
+ function normalizePath(p) {
31
+ const raw = String(p || '/').trim();
32
+ if (!raw || raw === '/') return '/';
33
+ let out = raw;
34
+ if (!out.startsWith('/')) out = `/${out}`;
35
+ out = out.replace(/\/+/g, '/');
36
+ if (out.length > 1 && out.endsWith('/')) out = out.slice(0, -1);
37
+ return out;
38
+ }
39
+
40
+ function normalizeName(name) {
41
+ const n = String(name || '').trim();
42
+ if (!n) {
43
+ const err = new Error('name is required');
44
+ err.code = 'VALIDATION';
45
+ throw err;
46
+ }
47
+ if (n.length > 200) {
48
+ const err = new Error('name is too long');
49
+ err.code = 'VALIDATION';
50
+ throw err;
51
+ }
52
+ return n;
53
+ }
54
+
55
+ function escapeRegex(value) {
56
+ return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ }
58
+
59
+ function buildPublicUrl(assetKey) {
60
+ return `/public/assets/${assetKey}`;
61
+ }
62
+
63
+ function computeNamespace({ orgId, driveType, driveId, parentPath }) {
64
+ const folderSlug =
65
+ parentPath === '/'
66
+ ? 'root'
67
+ : parentPath
68
+ .slice(1)
69
+ .toLowerCase()
70
+ .replace(/[^a-z0-9/\-]+/g, '-')
71
+ .replace(/\/+?/g, '/')
72
+ .split('/')
73
+ .filter(Boolean)
74
+ .join('--');
75
+ return `fms_${String(orgId)}_${driveType}_${String(driveId)}_${folderSlug || 'root'}`;
76
+ }
77
+
78
+ function buildNamespaceConfigForFolder(policyConfig, computedNamespace) {
79
+ // We want per-folder namespaces without creating GlobalSetting entries for each.
80
+ // Use the resolved policy from a stable namespace (default) but override key/keyPrefix.
81
+ return {
82
+ ...policyConfig,
83
+ key: computedNamespace,
84
+ keyPrefix: `assets/${computedNamespace}`,
85
+ };
86
+ }
87
+
88
+ async function listDrives({ userId, orgId }) {
89
+ const oid = normalizeObjectId(orgId, 'orgId');
90
+ const uid = normalizeObjectId(userId, 'userId');
91
+
92
+ const [groupMemberships, groups] = await Promise.all([
93
+ RbacGroupMember.find({ userId: uid }).select('groupId').lean(),
94
+ RbacGroup.find({ orgId: oid, status: 'active', isGlobal: false }).select('_id name description orgId').lean(),
95
+ ]);
96
+
97
+ const memberGroupIds = new Set(groupMemberships.map((m) => String(m.groupId)));
98
+ const orgGroups = groups.filter((g) => memberGroupIds.has(String(g._id)));
99
+
100
+ return {
101
+ drives: [
102
+ { driveType: 'user', driveId: String(uid), label: 'My Drive' },
103
+ ...orgGroups.map((g) => ({ driveType: 'group', driveId: String(g._id), label: g.name || 'Group Drive' })),
104
+ { driveType: 'org', driveId: String(oid), label: 'Org Drive' },
105
+ ],
106
+ };
107
+ }
108
+
109
+ async function listFolder({ orgId, driveType, driveId, parentPath }) {
110
+ const oid = normalizeObjectId(orgId, 'orgId');
111
+ const dt = normalizeDriveType(driveType);
112
+ const did = normalizeObjectId(driveId, 'driveId');
113
+ const path = normalizePath(parentPath);
114
+
115
+ const entries = await FileEntry.find({
116
+ orgId: oid,
117
+ driveType: dt,
118
+ driveId: did,
119
+ parentPath: path,
120
+ deletedAt: null,
121
+ })
122
+ .sort({ name: 1 })
123
+ .lean();
124
+
125
+ const assetIds = entries.map((e) => e.assetId).filter(Boolean);
126
+ const assets = assetIds.length
127
+ ? await Asset.find({ _id: { $in: assetIds } }).select('_id key originalName contentType sizeBytes status').lean()
128
+ : [];
129
+ const assetsById = new Map(assets.map((a) => [String(a._id), a]));
130
+
131
+ const files = entries.map((e) => {
132
+ const asset = assetsById.get(String(e.assetId));
133
+ return {
134
+ id: String(e._id),
135
+ name: e.name,
136
+ parentPath: e.parentPath,
137
+ visibility: e.visibility,
138
+ assetId: String(e.assetId),
139
+ assetKey: asset?.key || null,
140
+ publicUrl:
141
+ e.visibility === 'public' && asset?.key
142
+ ? buildPublicUrl(asset.key)
143
+ : null,
144
+ contentType: asset?.contentType || null,
145
+ size: asset?.sizeBytes ?? null,
146
+ createdAt: e.createdAt,
147
+ updatedAt: e.updatedAt,
148
+ };
149
+ });
150
+
151
+ // Virtual folders: compute immediate child folder names based on existing file parentPath values.
152
+ // Example: file at /toto/photo.jpg has parentPath=/toto. When listing '/', folder 'toto' should appear.
153
+ const prefix = path === '/' ? '/' : `${path}/`;
154
+ const prefixRegex = `^${escapeRegex(prefix)}`;
155
+ const descendantParentPaths = await FileEntry.find({
156
+ orgId: oid,
157
+ driveType: dt,
158
+ driveId: did,
159
+ parentPath: { $regex: prefixRegex },
160
+ deletedAt: null,
161
+ })
162
+ .select('parentPath')
163
+ .lean();
164
+
165
+ const folderNameSet = new Set();
166
+ for (const row of descendantParentPaths) {
167
+ const parent = normalizePath(row.parentPath);
168
+ if (parent === path) continue;
169
+
170
+ const remainder = parent.startsWith(prefix) ? parent.slice(prefix.length) : '';
171
+ const seg = remainder.split('/').filter(Boolean)[0];
172
+ if (seg) folderNameSet.add(seg);
173
+ }
174
+
175
+ const folders = Array.from(folderNameSet)
176
+ .sort((a, b) => a.localeCompare(b))
177
+ .map((name) => ({
178
+ name,
179
+ path: normalizePath(path === '/' ? `/${name}` : `${path}/${name}`),
180
+ }));
181
+
182
+ return {
183
+ entries: files,
184
+ files,
185
+ folders,
186
+ };
187
+ }
188
+
189
+ async function uploadFile({ userId, orgId, driveType, driveId, parentPath, name, buffer, contentType, overwrite = false, requestedVisibility }) {
190
+ const oid = normalizeObjectId(orgId, 'orgId');
191
+ const uid = normalizeObjectId(userId, 'userId');
192
+ const dt = normalizeDriveType(driveType);
193
+ const did = normalizeObjectId(driveId, 'driveId');
194
+ const path = normalizePath(parentPath);
195
+ const filename = normalizeName(name);
196
+
197
+ const existing = await FileEntry.findOne({
198
+ orgId: oid,
199
+ driveType: dt,
200
+ driveId: did,
201
+ parentPath: path,
202
+ name: filename,
203
+ deletedAt: null,
204
+ });
205
+
206
+ const computedNamespace = computeNamespace({ orgId: oid, driveType: dt, driveId: did, parentPath: path });
207
+ const policyConfig = await uploadNamespacesService.resolveNamespace('default');
208
+ const baseNamespaceConfig = buildNamespaceConfigForFolder(policyConfig, computedNamespace);
209
+
210
+ const { maxUploadBytes } = await fileManagerStoragePolicyService.resolveEffectiveLimits({
211
+ userId: uid,
212
+ orgId: oid,
213
+ driveType: dt,
214
+ driveId: did,
215
+ });
216
+
217
+ // File Manager should accept any file type. `validateUpload()` only enforces
218
+ // allowed content types if the array is non-empty.
219
+ const namespaceConfig = {
220
+ ...baseNamespaceConfig,
221
+ allowedContentTypes: [],
222
+ maxFileSizeBytes: maxUploadBytes,
223
+ };
224
+
225
+ // File Manager max size is enforced via FILE_MANAGER_MAX_UPLOAD_BYTES.
226
+ // Provide it as the effective hard-cap for validation.
227
+ const hardCapMaxFileSizeBytes = maxUploadBytes;
228
+
229
+ const validation = uploadNamespacesService.validateUpload({
230
+ namespaceConfig,
231
+ contentType,
232
+ sizeBytes: buffer.length,
233
+ hardCapMaxFileSizeBytes,
234
+ });
235
+
236
+ if (!validation.ok) {
237
+ const err = new Error('Upload rejected by namespace policy');
238
+ err.code = 'UPLOAD_REJECTED';
239
+ err.details = { namespace: namespaceConfig.key, hardCapMaxFileSizeBytes, errors: validation.errors };
240
+ throw err;
241
+ }
242
+
243
+ const visibility = uploadNamespacesService.computeVisibility({
244
+ namespaceConfig,
245
+ requestedVisibility,
246
+ });
247
+
248
+ if (existing) {
249
+ if (!overwrite) {
250
+ const err = new Error('File already exists');
251
+ err.code = 'CONFLICT';
252
+ err.details = { existingFileId: String(existing._id) };
253
+ throw err;
254
+ }
255
+
256
+ const asset = await Asset.findById(existing.assetId);
257
+ if (!asset) {
258
+ const err = new Error('Underlying asset not found');
259
+ err.code = 'NOT_FOUND';
260
+ throw err;
261
+ }
262
+
263
+ const { provider, bucket } = await objectStorage.putObject({
264
+ key: asset.key,
265
+ body: buffer,
266
+ contentType,
267
+ });
268
+
269
+ asset.provider = provider;
270
+ asset.bucket = bucket;
271
+ asset.contentType = contentType;
272
+ asset.sizeBytes = buffer.length;
273
+ asset.originalName = filename;
274
+ asset.namespace = namespaceConfig.key;
275
+ // Overwrite replaces the object bytes only; visibility should not be changed.
276
+ await asset.save();
277
+
278
+ if (existing.visibility !== asset.visibility) {
279
+ existing.visibility = asset.visibility;
280
+ await existing.save();
281
+ }
282
+
283
+ return { file: { id: String(existing._id), assetId: String(asset._id), visibility: asset.visibility } };
284
+ }
285
+
286
+ const key = uploadNamespacesService.generateObjectKey({
287
+ namespaceConfig,
288
+ originalName: filename,
289
+ });
290
+
291
+ const { provider, bucket } = await objectStorage.putObject({
292
+ key,
293
+ body: buffer,
294
+ contentType,
295
+ });
296
+
297
+ const asset = await Asset.create({
298
+ key,
299
+ provider,
300
+ bucket,
301
+ originalName: filename,
302
+ contentType,
303
+ sizeBytes: buffer.length,
304
+ visibility,
305
+ namespace: namespaceConfig.key,
306
+ visibilityEnforced: Boolean(namespaceConfig.enforceVisibility),
307
+ ownerUserId: dt === 'user' ? uid : null,
308
+ orgId: oid,
309
+ status: 'uploaded',
310
+ });
311
+
312
+ const entry = await FileEntry.create({
313
+ orgId: oid,
314
+ driveType: dt,
315
+ driveId: did,
316
+ parentPath: path,
317
+ name: filename,
318
+ assetId: asset._id,
319
+ visibility: asset.visibility,
320
+ deletedAt: null,
321
+ });
322
+
323
+ return { file: { id: String(entry._id), assetId: String(asset._id), visibility: asset.visibility } };
324
+ }
325
+
326
+ async function getFileEntry({ orgId, driveType, driveId, fileId }) {
327
+ const oid = normalizeObjectId(orgId, 'orgId');
328
+ const dt = normalizeDriveType(driveType);
329
+ const did = normalizeObjectId(driveId, 'driveId');
330
+ const fid = normalizeObjectId(fileId, 'fileId');
331
+
332
+ const entry = await FileEntry.findOne({ _id: fid, orgId: oid, driveType: dt, driveId: did, deletedAt: null }).lean();
333
+ if (!entry) {
334
+ const err = new Error('File not found');
335
+ err.code = 'NOT_FOUND';
336
+ throw err;
337
+ }
338
+
339
+ const asset = await Asset.findById(entry.assetId).lean();
340
+ if (!asset) {
341
+ const err = new Error('Underlying asset not found');
342
+ err.code = 'NOT_FOUND';
343
+ throw err;
344
+ }
345
+
346
+ return { entry, asset };
347
+ }
348
+
349
+ async function downloadFile({ orgId, driveType, driveId, fileId }) {
350
+ const { entry, asset } = await getFileEntry({ orgId, driveType, driveId, fileId });
351
+ const result = await objectStorage.getObject({ key: asset.key });
352
+ if (!result) {
353
+ const err = new Error('File not found in storage');
354
+ err.code = 'NOT_FOUND';
355
+ throw err;
356
+ }
357
+
358
+ return {
359
+ entry,
360
+ asset,
361
+ contentType: result.contentType || asset.contentType,
362
+ body: result.body,
363
+ };
364
+ }
365
+
366
+ async function deleteFile({ orgId, driveType, driveId, fileId }) {
367
+ const { entry, asset } = await getFileEntry({ orgId, driveType, driveId, fileId });
368
+
369
+ await objectStorage.deleteObject({ key: asset.key });
370
+
371
+ await Asset.findByIdAndUpdate(asset._id, { $set: { status: 'deleted' } });
372
+ await FileEntry.findByIdAndUpdate(entry._id, { $set: { deletedAt: new Date() } });
373
+
374
+ return { success: true };
375
+ }
376
+
377
+ async function setShare({ orgId, driveType, driveId, fileId, enabled }) {
378
+ const { entry, asset } = await getFileEntry({ orgId, driveType, driveId, fileId });
379
+
380
+ if (asset.visibilityEnforced) {
381
+ const err = new Error('Visibility is enforced by the upload namespace for this file');
382
+ err.code = 'VISIBILITY_ENFORCED';
383
+ throw err;
384
+ }
385
+
386
+ const newVisibility = enabled ? 'public' : 'private';
387
+
388
+ await Asset.findByIdAndUpdate(asset._id, { $set: { visibility: newVisibility } });
389
+ await FileEntry.findByIdAndUpdate(entry._id, { $set: { visibility: newVisibility } });
390
+
391
+ return {
392
+ success: true,
393
+ visibility: newVisibility,
394
+ publicUrl: newVisibility === 'public' ? buildPublicUrl(asset.key) : null,
395
+ };
396
+ }
397
+
398
+ async function updateFile({ orgId, driveType, driveId, fileId, name, parentPath }) {
399
+ const oid = normalizeObjectId(orgId, 'orgId');
400
+ const dt = normalizeDriveType(driveType);
401
+ const did = normalizeObjectId(driveId, 'driveId');
402
+ const fid = normalizeObjectId(fileId, 'fileId');
403
+
404
+ const patch = {};
405
+ if (name !== undefined) patch.name = normalizeName(name);
406
+ if (parentPath !== undefined) patch.parentPath = normalizePath(parentPath);
407
+
408
+ if (Object.keys(patch).length === 0) {
409
+ const err = new Error('No updates provided');
410
+ err.code = 'VALIDATION';
411
+ throw err;
412
+ }
413
+
414
+ const entry = await FileEntry.findOne({ _id: fid, orgId: oid, driveType: dt, driveId: did, deletedAt: null });
415
+ if (!entry) {
416
+ const err = new Error('File not found');
417
+ err.code = 'NOT_FOUND';
418
+ throw err;
419
+ }
420
+
421
+ const newName = patch.name ?? entry.name;
422
+ const newParentPath = patch.parentPath ?? entry.parentPath;
423
+
424
+ // Uniqueness: same folder + name
425
+ const conflict = await FileEntry.findOne({
426
+ _id: { $ne: entry._id },
427
+ orgId: oid,
428
+ driveType: dt,
429
+ driveId: did,
430
+ parentPath: newParentPath,
431
+ name: newName,
432
+ deletedAt: null,
433
+ }).select('_id').lean();
434
+
435
+ if (conflict) {
436
+ const err = new Error('A file with that name already exists in the target folder');
437
+ err.code = 'CONFLICT';
438
+ err.details = { existingFileId: String(conflict._id) };
439
+ throw err;
440
+ }
441
+
442
+ // Update entry first
443
+ entry.name = newName;
444
+ entry.parentPath = newParentPath;
445
+ await entry.save();
446
+
447
+ // Update Asset.namespace to match new folder (no object key move).
448
+ const computedNamespace = computeNamespace({ orgId: oid, driveType: dt, driveId: did, parentPath: newParentPath });
449
+ const policyConfig = await uploadNamespacesService.resolveNamespace('default');
450
+ const namespaceConfig = buildNamespaceConfigForFolder(policyConfig, computedNamespace);
451
+
452
+ await Asset.findByIdAndUpdate(entry.assetId, { $set: { namespace: namespaceConfig.key, originalName: newName } });
453
+
454
+ return {
455
+ file: {
456
+ id: String(entry._id),
457
+ name: entry.name,
458
+ parentPath: entry.parentPath,
459
+ },
460
+ };
461
+ }
462
+
463
+ module.exports = {
464
+ normalizePath,
465
+ normalizeName,
466
+ listDrives,
467
+ listFolder,
468
+ uploadFile,
469
+ updateFile,
470
+ downloadFile,
471
+ deleteFile,
472
+ setShare,
473
+ computeNamespace,
474
+ buildPublicUrl,
475
+ };