streamby-core 0.37.0 → 0.39.0

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 (50) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/LICENSE +661 -661
  3. package/README.md +573 -512
  4. package/dist/adapters/database/connectionManager.js +124 -124
  5. package/dist/adapters/database/sql.js +1 -1
  6. package/dist/adapters/storage/s3.d.ts +3 -2
  7. package/dist/adapters/storage/s3.js +18 -5
  8. package/dist/mcp/server.js +12 -0
  9. package/dist/mcp/tools.d.ts +2 -0
  10. package/dist/mcp/tools.js +107 -14
  11. package/dist/mcp/workflowNodes.d.ts +24 -0
  12. package/dist/mcp/workflowNodes.js +48 -0
  13. package/dist/middleware/createRouter.js +2 -0
  14. package/dist/middleware/routes/apiConnection.d.ts +3 -0
  15. package/dist/middleware/routes/apiConnection.js +100 -0
  16. package/dist/middleware/routes/credential.d.ts +3 -0
  17. package/dist/middleware/routes/credential.js +80 -0
  18. package/dist/middleware/routes/deliverable.d.ts +3 -0
  19. package/dist/middleware/routes/deliverable.js +131 -0
  20. package/dist/middleware/routes/export.js +18 -0
  21. package/dist/middleware/routes/production.js +60 -67
  22. package/dist/middleware/routes/project.js +39 -11
  23. package/dist/middleware/routes/storage.js +115 -24
  24. package/dist/models/Model.js +9 -9
  25. package/dist/services/apiConnection.d.ts +18 -0
  26. package/dist/services/apiConnection.js +65 -0
  27. package/dist/services/apiTokenPermissions.js +7 -3
  28. package/dist/services/assetVersions.d.ts +48 -0
  29. package/dist/services/assetVersions.js +84 -0
  30. package/dist/services/credential.d.ts +8 -0
  31. package/dist/services/credential.js +70 -0
  32. package/dist/services/deliverables.d.ts +26 -0
  33. package/dist/services/deliverables.js +55 -0
  34. package/dist/services/mediaProcessor.js +3 -1
  35. package/dist/services/pipeline.d.ts +2 -0
  36. package/dist/services/pipeline.js +292 -0
  37. package/dist/services/production.d.ts +2 -0
  38. package/dist/services/production.js +13 -0
  39. package/dist/services/productionFields.d.ts +16 -0
  40. package/dist/services/productionFields.js +80 -0
  41. package/dist/services/signedUrls.d.ts +6 -0
  42. package/dist/services/signedUrls.js +9 -0
  43. package/dist/types/index.d.ts +7 -2
  44. package/dist/utils/crypto.d.ts +3 -0
  45. package/dist/utils/crypto.js +32 -0
  46. package/dist/utils/nosqlDb.d.ts +2 -0
  47. package/dist/utils/nosqlDb.js +11 -0
  48. package/dist/utils/sanitize.d.ts +1 -0
  49. package/dist/utils/sanitize.js +14 -0
  50. package/package.json +1 -1
@@ -9,12 +9,16 @@ const auth_1 = require("../../utils/auth");
9
9
  const connectionManager_1 = require("../../adapters/database/connectionManager");
10
10
  const manager_1 = require("../../models/manager");
11
11
  const crypto_1 = __importDefault(require("crypto"));
12
+ const deliverables_1 = require("../../services/deliverables");
13
+ const productionFields_1 = require("../../services/productionFields");
12
14
  function getDb() {
13
15
  const ids = (0, connectionManager_1.getConnectedIds)().filter(id => (0, connectionManager_1.getConnection)(id).type === 'nosql');
14
16
  if (!ids.length)
15
17
  return null;
16
18
  return (0, connectionManager_1.getConnection)(ids[0]).client.db();
17
19
  }
20
+ const SHOT_FIELDS = ['name', 'description', 'status', 'assignees', 'assetId', 'exportId', 'dueDate', 'order'];
21
+ const TASK_FIELDS = ['name', 'status', 'priority', 'assignee', 'dueDate', 'notes'];
18
22
  function productionRouter(_config) {
19
23
  const router = (0, express_1.Router)();
20
24
  const Project = (0, manager_1.getModel)('projects');
@@ -45,14 +49,15 @@ function productionRouter(_config) {
45
49
  try {
46
50
  const auth = req.auth;
47
51
  const { projectId } = req.params;
48
- const { name, description, order } = req.body;
49
- if (!name)
50
- return res.status(400).json({ message: 'name is required' });
51
52
  const project = await Project.findOne({ _id: projectId });
52
53
  if (!project)
53
54
  return res.status(404).json({ message: 'Project not found' });
54
55
  if (!(0, auth_1.canEditProject)(project, auth.userId))
55
56
  return res.status(403).json({ message: 'Unauthorized' });
57
+ const checked = (0, productionFields_1.checkProductionFields)(req.body, ['name', 'description', 'order'], project, { creating: true });
58
+ if ('error' in checked)
59
+ return res.status(400).json({ message: checked.error });
60
+ const { values } = checked;
56
61
  const db = getDb();
57
62
  if (!db)
58
63
  return res.status(500).json({ message: 'Database not available' });
@@ -60,9 +65,9 @@ function productionRouter(_config) {
60
65
  const sequence = {
61
66
  id: crypto_1.default.randomUUID(),
62
67
  projectId,
63
- name,
64
- description: description ?? null,
65
- order: order ?? 0,
68
+ name: values.name,
69
+ description: values.description ?? null,
70
+ order: values.order ?? 0,
66
71
  createdBy: auth.userId,
67
72
  createdAt: now,
68
73
  updatedAt: now,
@@ -86,14 +91,10 @@ function productionRouter(_config) {
86
91
  const db = getDb();
87
92
  if (!db)
88
93
  return res.status(500).json({ message: 'Database not available' });
89
- const { name, description, order } = req.body;
90
- const $set = { updatedAt: new Date() };
91
- if (name !== undefined)
92
- $set.name = name;
93
- if (description !== undefined)
94
- $set.description = description;
95
- if (order !== undefined)
96
- $set.order = order;
94
+ const checked = (0, productionFields_1.checkProductionFields)(req.body, ['name', 'description', 'order'], project);
95
+ if ('error' in checked)
96
+ return res.status(400).json({ message: checked.error });
97
+ const $set = { ...checked.values, updatedAt: new Date() };
97
98
  const result = await db.collection('production_sequences').findOneAndUpdate({ id: seqId, projectId }, { $set }, { returnDocument: 'after' });
98
99
  if (!result)
99
100
  return res.status(404).json({ message: 'Sequence not found' });
@@ -115,8 +116,15 @@ function productionRouter(_config) {
115
116
  const db = getDb();
116
117
  if (!db)
117
118
  return res.status(500).json({ message: 'Database not available' });
118
- await db.collection('production_sequences').deleteOne({ id: seqId, projectId });
119
+ // With its shots, and their tasks and deliverables: nothing is left behind.
120
+ const shotIds = (await db.collection('production_shots').find({ sequenceId: seqId, projectId }).toArray()).map(s => s.id);
121
+ const { deletedCount } = await db.collection('production_sequences').deleteOne({ id: seqId, projectId });
122
+ if (!deletedCount)
123
+ return res.status(404).json({ message: 'Sequence not found' });
119
124
  await db.collection('production_shots').deleteMany({ sequenceId: seqId, projectId });
125
+ if (shotIds.length)
126
+ await db.collection('production_tasks').deleteMany({ projectId, shotId: { $in: shotIds } });
127
+ await (0, deliverables_1.deleteDeliverablesOf)(projectId, { shotIds });
120
128
  res.json({ message: 'Sequence deleted' });
121
129
  }
122
130
  catch (err) {
@@ -150,30 +158,34 @@ function productionRouter(_config) {
150
158
  try {
151
159
  const auth = req.auth;
152
160
  const { projectId, seqId } = req.params;
153
- const { name, description, status, assignedTo, assetId, exportId, dueDate, order } = req.body;
154
- if (!name)
155
- return res.status(400).json({ message: 'name is required' });
156
161
  const project = await Project.findOne({ _id: projectId });
157
162
  if (!project)
158
163
  return res.status(404).json({ message: 'Project not found' });
159
164
  if (!(0, auth_1.canEditProject)(project, auth.userId))
160
165
  return res.status(403).json({ message: 'Unauthorized' });
166
+ const checked = (0, productionFields_1.checkProductionFields)(req.body, SHOT_FIELDS, project, { creating: true });
167
+ if ('error' in checked)
168
+ return res.status(400).json({ message: checked.error });
169
+ const { values } = checked;
161
170
  const db = getDb();
162
171
  if (!db)
163
172
  return res.status(500).json({ message: 'Database not available' });
173
+ if (!(await db.collection('production_sequences').findOne({ id: seqId, projectId }))) {
174
+ return res.status(404).json({ message: 'Sequence not found' });
175
+ }
164
176
  const now = new Date();
165
177
  const shot = {
166
178
  id: crypto_1.default.randomUUID(),
167
179
  sequenceId: seqId,
168
180
  projectId,
169
- name,
170
- description: description ?? null,
171
- order: order ?? 0,
172
- status: status ?? 'todo',
173
- assignedTo: assignedTo ?? [],
174
- assetId: assetId ?? null,
175
- exportId: exportId ?? null,
176
- dueDate: dueDate ? new Date(dueDate) : null,
181
+ name: values.name,
182
+ description: values.description ?? null,
183
+ order: values.order ?? 0,
184
+ status: values.status ?? 'todo',
185
+ assignedTo: values.assignedTo ?? [],
186
+ assetId: values.assetId ?? null,
187
+ exportId: values.exportId ?? null,
188
+ dueDate: values.dueDate ?? null,
177
189
  createdBy: auth.userId,
178
190
  createdAt: now,
179
191
  updatedAt: now,
@@ -197,24 +209,10 @@ function productionRouter(_config) {
197
209
  const db = getDb();
198
210
  if (!db)
199
211
  return res.status(500).json({ message: 'Database not available' });
200
- const { name, description, status, assignedTo, assetId, exportId, dueDate, order } = req.body;
201
- const $set = { updatedAt: new Date() };
202
- if (name !== undefined)
203
- $set.name = name;
204
- if (description !== undefined)
205
- $set.description = description;
206
- if (status !== undefined)
207
- $set.status = status;
208
- if (assignedTo !== undefined)
209
- $set.assignedTo = assignedTo;
210
- if (assetId !== undefined)
211
- $set.assetId = assetId;
212
- if (exportId !== undefined)
213
- $set.exportId = exportId;
214
- if (dueDate !== undefined)
215
- $set.dueDate = dueDate ? new Date(dueDate) : null;
216
- if (order !== undefined)
217
- $set.order = order;
212
+ const checked = (0, productionFields_1.checkProductionFields)(req.body, SHOT_FIELDS, project);
213
+ if ('error' in checked)
214
+ return res.status(400).json({ message: checked.error });
215
+ const $set = { ...checked.values, updatedAt: new Date() };
218
216
  const result = await db.collection('production_shots').findOneAndUpdate({ id: shotId, projectId }, { $set }, { returnDocument: 'after' });
219
217
  if (!result)
220
218
  return res.status(404).json({ message: 'Shot not found' });
@@ -238,6 +236,7 @@ function productionRouter(_config) {
238
236
  return res.status(500).json({ message: 'Database not available' });
239
237
  await db.collection('production_shots').deleteOne({ id: shotId, projectId });
240
238
  await db.collection('production_tasks').deleteMany({ shotId, projectId });
239
+ await (0, deliverables_1.deleteDeliverablesOf)(projectId, { shotIds: [shotId] });
241
240
  res.json({ message: 'Shot deleted' });
242
241
  }
243
242
  catch (err) {
@@ -271,28 +270,32 @@ function productionRouter(_config) {
271
270
  try {
272
271
  const auth = req.auth;
273
272
  const { projectId, shotId } = req.params;
274
- const { name, status, priority, assignedTo, dueDate, notes } = req.body;
275
- if (!name)
276
- return res.status(400).json({ message: 'name is required' });
277
273
  const project = await Project.findOne({ _id: projectId });
278
274
  if (!project)
279
275
  return res.status(404).json({ message: 'Project not found' });
280
276
  if (!(0, auth_1.canEditProject)(project, auth.userId))
281
277
  return res.status(403).json({ message: 'Unauthorized' });
278
+ const checked = (0, productionFields_1.checkProductionFields)(req.body, TASK_FIELDS, project, { creating: true });
279
+ if ('error' in checked)
280
+ return res.status(400).json({ message: checked.error });
281
+ const { values } = checked;
282
282
  const db = getDb();
283
283
  if (!db)
284
284
  return res.status(500).json({ message: 'Database not available' });
285
+ if (!(await db.collection('production_shots').findOne({ id: shotId, projectId }))) {
286
+ return res.status(404).json({ message: 'Shot not found' });
287
+ }
285
288
  const now = new Date();
286
289
  const task = {
287
290
  id: crypto_1.default.randomUUID(),
288
291
  shotId,
289
292
  projectId,
290
- name,
291
- status: status ?? 'todo',
292
- priority: priority ?? 'medium',
293
- assignedTo: assignedTo ?? null,
294
- dueDate: dueDate ? new Date(dueDate) : null,
295
- notes: notes ?? null,
293
+ name: values.name,
294
+ status: values.status ?? 'todo',
295
+ priority: values.priority ?? 'medium',
296
+ assignedTo: values.assignedTo ?? null,
297
+ dueDate: values.dueDate ?? null,
298
+ notes: values.notes ?? null,
296
299
  createdBy: auth.userId,
297
300
  createdAt: now,
298
301
  updatedAt: now,
@@ -316,20 +319,10 @@ function productionRouter(_config) {
316
319
  const db = getDb();
317
320
  if (!db)
318
321
  return res.status(500).json({ message: 'Database not available' });
319
- const { name, status, priority, assignedTo, dueDate, notes } = req.body;
320
- const $set = { updatedAt: new Date() };
321
- if (name !== undefined)
322
- $set.name = name;
323
- if (status !== undefined)
324
- $set.status = status;
325
- if (priority !== undefined)
326
- $set.priority = priority;
327
- if (assignedTo !== undefined)
328
- $set.assignedTo = assignedTo;
329
- if (dueDate !== undefined)
330
- $set.dueDate = dueDate ? new Date(dueDate) : null;
331
- if (notes !== undefined)
332
- $set.notes = notes;
322
+ const checked = (0, productionFields_1.checkProductionFields)(req.body, TASK_FIELDS, project);
323
+ if ('error' in checked)
324
+ return res.status(400).json({ message: checked.error });
325
+ const $set = { ...checked.values, updatedAt: new Date() };
333
326
  const result = await db.collection('production_tasks').findOneAndUpdate({ id: taskId, shotId, projectId }, { $set }, { returnDocument: 'after' });
334
327
  if (!result)
335
328
  return res.status(404).json({ message: 'Task not found' });
@@ -10,6 +10,8 @@ const sanitize_1 = require("../../utils/sanitize");
10
10
  const projectIntegration_1 = require("../../services/projectIntegration");
11
11
  const auth_2 = require("../../services/auth");
12
12
  const apiTokens_1 = require("../../services/apiTokens");
13
+ const nosqlDb_1 = require("../../utils/nosqlDb");
14
+ const production_1 = require("../../services/production");
13
15
  function projectRouter(config) {
14
16
  const router = (0, express_1.Router)();
15
17
  const Project = (0, manager_1.getModel)('projects');
@@ -194,17 +196,12 @@ function projectRouter(config) {
194
196
  if (!(0, auth_1.isProjectMember)(project, auth.userId)) {
195
197
  return res.status(403).json({ message: 'Unauthorized project access' });
196
198
  }
197
- const exports = (project.exports || []).map((e) => ({
198
- id: e.id,
199
- name: e.name,
200
- description: e.description,
201
- method: e.method,
202
- private: e.private,
203
- useConnections: e.useConnections,
204
- useCredentials: e.useCredentials,
205
- }));
199
+ const exports = (0, sanitize_1.exportSummaries)(project.exports);
206
200
  const sanitized = (0, sanitize_1.sanitizeProject)({ ...project, id: project._id || project.id, _id: undefined, connections: project.connections || [], exports });
207
- res.json({ project: sanitized, message: 'Project fetched successfully' });
201
+ // Whether it has production data, for its Production section to show even without a
202
+ // production node in its workflow.
203
+ const hasProductionData = !!(await (0, nosqlDb_1.nosqlDb)()?.collection('production_sequences').findOne({ projectId }));
204
+ res.json({ project: { ...sanitized, hasProductionData }, message: 'Project fetched successfully' });
208
205
  }
209
206
  catch (err) {
210
207
  res.status(500).json({ message: 'Failed to fetch project', details: err.message });
@@ -257,8 +254,9 @@ function projectRouter(config) {
257
254
  return res.status(403).json({ message: 'Only a project admin can delete it' });
258
255
  }
259
256
  await Project.useDbType(project.dbType).delete({ _id: projectId });
260
- // Its tokens have nothing left to reach.
257
+ // Its tokens have nothing left to reach, and its production data nothing to belong to.
261
258
  await (0, apiTokens_1.deleteProjectApiTokens)(String(projectId));
259
+ await (0, production_1.deleteProductionData)(String(projectId));
262
260
  const allProjects = await Project.find({});
263
261
  const projects = allProjects
264
262
  .filter(project => {
@@ -375,6 +373,21 @@ function projectRouter(config) {
375
373
  res.status(500).json({ message: 'Failed to update project image', details: err.message });
376
374
  }
377
375
  });
376
+ // ─── A project's allowed origins, on their own ────────────────────────────
377
+ router.get('/projects/:id/origins', async (req, res) => {
378
+ try {
379
+ const auth = req.auth;
380
+ const project = await Project.findOne({ _id: req.params.id });
381
+ if (!project)
382
+ return res.status(404).json({ message: 'Project not found' });
383
+ if (!(0, auth_1.isProjectMember)(project, auth.userId))
384
+ return res.status(403).json({ message: 'Unauthorized project access' });
385
+ return res.status(200).json({ data: project.allowedOrigin || [] });
386
+ }
387
+ catch (err) {
388
+ res.status(500).json({ message: 'Failed to fetch project origins', details: err.message });
389
+ }
390
+ });
378
391
  router.patch('/projects/:id/origins', async (req, res) => {
379
392
  try {
380
393
  const auth = req.auth;
@@ -397,6 +410,21 @@ function projectRouter(config) {
397
410
  res.status(500).json({ message: 'Failed to update project origins', details: err.message });
398
411
  }
399
412
  });
413
+ // ─── A project's integrations, on their own ───────────────────────────────
414
+ router.get('/projects/:id/integrations', async (req, res) => {
415
+ try {
416
+ const auth = req.auth;
417
+ const project = await Project.findOne({ _id: req.params.id });
418
+ if (!project)
419
+ return res.status(404).json({ message: 'Project not found' });
420
+ if (!(0, auth_1.isProjectMember)(project, auth.userId))
421
+ return res.status(403).json({ message: 'Unauthorized project access' });
422
+ return res.status(200).json({ data: (0, sanitize_1.sanitizeIntegrationConnections)(project.integrationConnections) });
423
+ }
424
+ catch (err) {
425
+ res.status(500).json({ message: 'Failed to fetch project integrations', details: err.message });
426
+ }
427
+ });
400
428
  // ─── Add an integration to an existing project ───────────────────────────
401
429
  router.post('/projects/:id/integrations', async (req, res) => {
402
430
  try {
@@ -15,6 +15,9 @@ const builtinAccess_1 = require("../../utils/builtinAccess");
15
15
  const connectionResolver_1 = require("../../services/connectionResolver");
16
16
  const mongodb_1 = require("mongodb");
17
17
  const crypto_1 = __importDefault(require("crypto"));
18
+ const signedUrls_1 = require("../../services/signedUrls");
19
+ const assetVersions_1 = require("../../services/assetVersions");
20
+ const deliverables_1 = require("../../services/deliverables");
18
21
  const VALID_CATEGORIES = ['images', 'audios', 'videos', '3d-models'];
19
22
  const VALID_3D_EXTENSIONS = new Set(['.glb', '.gltf', '.obj', '.fbx', '.stl', '.ply']);
20
23
  function validateContentType(contentType, filename, category) {
@@ -135,7 +138,7 @@ function storageRouter(config) {
135
138
  const storageKey = `${projectId}/${category}/${fileId}${ext}`;
136
139
  const collection = getRawFilesCollection();
137
140
  if (collection) {
138
- await collection.insertOne({
141
+ const fileDoc = {
139
142
  _id: new mongodb_1.ObjectId(),
140
143
  fileId,
141
144
  projectId,
@@ -145,10 +148,13 @@ function storageRouter(config) {
145
148
  contentType,
146
149
  uploadedBy: auth.userId,
147
150
  createdAt: new Date(),
148
- });
151
+ };
152
+ await collection.insertOne(fileDoc);
153
+ await (0, assetVersions_1.recordFirstVersion)(fileDoc);
149
154
  }
150
- const url = await adapter.getPresignedUploadUrl(storageKey, contentType);
151
- res.json({ url, fileId, storageKey, displayName: fileName });
155
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
156
+ const url = await adapter.getPresignedUploadUrl(storageKey, contentType, expiresIn);
157
+ res.json({ url, fileId, storageKey, displayName: fileName, ...expiry(expiresIn) });
152
158
  }
153
159
  catch (err) {
154
160
  res.status(500).json({ message: 'Failed to generate upload URL', details: err.message });
@@ -165,7 +171,7 @@ function storageRouter(config) {
165
171
  const project = await Project.findOne({ _id: projectId });
166
172
  if (!project)
167
173
  return res.status(404).json({ message: 'Project not found' });
168
- if (!(0, auth_1.isProjectMember)(project, auth.userId))
174
+ if (!(0, auth_1.canEditProject)(project, auth.userId))
169
175
  return res.status(403).json({ message: 'Unauthorized access' });
170
176
  const collection = getRawFilesCollection();
171
177
  if (!collection)
@@ -179,9 +185,21 @@ function storageRouter(config) {
179
185
  if (!adapter.getPresignedUploadUrl) {
180
186
  return res.status(501).json({ message: 'Storage adapter does not support presigned uploads' });
181
187
  }
182
- const url = await adapter.getPresignedUploadUrl(fileDoc.storageKey, contentType);
188
+ // The new version goes to the asset's own key, so its URL doesn't change; the current
189
+ // one is archived first, and without a way to archive it nothing is replaced.
190
+ if (!adapter.copyFile)
191
+ return res.status(501).json({ message: 'Storage adapter cannot keep versions' });
192
+ let version;
193
+ try {
194
+ version = await (0, assetVersions_1.archiveCurrentVersion)(fileDoc, { fileName, contentType, userId: auth.userId }, adapter.copyFile.bind(adapter));
195
+ }
196
+ catch (err) {
197
+ return res.status(502).json({ message: 'Failed to archive the current version; nothing was replaced', details: err.message });
198
+ }
199
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
200
+ const url = await adapter.getPresignedUploadUrl(fileDoc.storageKey, contentType, expiresIn);
183
201
  await collection.updateOne({ fileId, projectId }, { $set: { contentType, updatedAt: new Date() } });
184
- res.json({ url, storageKey: fileDoc.storageKey, fileId });
202
+ res.json({ url, storageKey: fileDoc.storageKey, fileId, versionId: version.versionId, version: version.version, ...expiry(expiresIn) });
185
203
  }
186
204
  catch (err) {
187
205
  res.status(500).json({ message: 'Failed to generate replace URL', details: err.message });
@@ -227,8 +245,7 @@ function storageRouter(config) {
227
245
  const fileDoc = await collection.findOne({ fileId, projectId });
228
246
  if (!fileDoc)
229
247
  return res.status(404).json({ message: 'File not found' });
230
- if (adapter.deleteFile)
231
- await adapter.deleteFile(fileDoc.storageKey);
248
+ await deleteAsset(fileDoc, adapter);
232
249
  await collection.deleteOne({ fileId, projectId });
233
250
  res.json({ message: 'File deleted successfully' });
234
251
  }
@@ -249,7 +266,7 @@ function storageRouter(config) {
249
266
  if (!collection)
250
267
  return res.status(500).json({ message: 'Database not available' });
251
268
  const files = await collection.find({ projectId }).sort({ createdAt: -1 }).limit(12).toArray();
252
- const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter)));
269
+ const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
253
270
  res.json({ data });
254
271
  }
255
272
  catch (err) {
@@ -272,13 +289,61 @@ function storageRouter(config) {
272
289
  if (!collection)
273
290
  return res.status(500).json({ message: 'Database not available' });
274
291
  const files = await collection.find({ projectId, category }).sort({ createdAt: -1 }).toArray();
275
- const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter)));
292
+ const data = await Promise.all(files.map(f => resolveFileUrl(f, adapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
276
293
  res.json({ data });
277
294
  }
278
295
  catch (err) {
279
296
  res.status(500).json({ message: 'Failed to list files', details: err.message });
280
297
  }
281
298
  });
299
+ // A short-lived URL to download an asset, or one of its versions, wherever it's stored
300
+ // (the built-in storage or one of the project's storage connections). It's for that file
301
+ // only, and downloads it with its name.
302
+ router.get('/projects/:projectId/assets/:fileId/download-url', async (req, res) => {
303
+ try {
304
+ const auth = req.auth;
305
+ const { projectId, fileId } = req.params;
306
+ const { versionId } = req.query;
307
+ const project = await Project.findOne({ _id: projectId });
308
+ if (!project)
309
+ return res.status(404).json({ message: 'Project not found' });
310
+ if (!(0, auth_1.isProjectMember)(project, auth.userId))
311
+ return res.status(403).json({ message: 'Unauthorized access' });
312
+ const collection = getRawFilesCollection();
313
+ if (!collection)
314
+ return res.status(500).json({ message: 'Database not available' });
315
+ const fileDoc = await collection.findOne({ fileId, projectId });
316
+ if (!fileDoc)
317
+ return res.status(404).json({ message: 'File not found' });
318
+ let storageKey = fileDoc.storageKey;
319
+ let fileName = fileDoc.displayName;
320
+ let contentType = fileDoc.contentType;
321
+ let version;
322
+ if (versionId) {
323
+ const found = await (0, assetVersions_1.findVersion)(projectId, fileId, versionId);
324
+ if (!found)
325
+ return res.status(404).json({ message: 'Version not found' });
326
+ storageKey = found.storageKey;
327
+ fileName = found.fileName ?? fileName;
328
+ contentType = found.contentType ?? contentType;
329
+ version = found.version;
330
+ }
331
+ // Its storage connection's adapter; files stored before connections had their own row
332
+ // (no connection, or the built-in provider's id) are in the default storage.
333
+ const connId = fileDoc.storageConnectionId;
334
+ const fileAdapter = connId && findStorageConn(project, connId) ? await resolveConnAdapter(connId, project, auth) : adapter;
335
+ if ('error' in fileAdapter)
336
+ return res.status(fileAdapter.status).json({ message: fileAdapter.error });
337
+ if (!fileAdapter.getPresignedGetUrl)
338
+ return res.status(501).json({ message: 'Storage adapter does not support download URLs' });
339
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'download') ?? 3600;
340
+ const url = await fileAdapter.getPresignedGetUrl(storageKey, expiresIn, fileName);
341
+ res.json({ url, fileId, fileName, contentType: contentType ?? null, ...(version ? { versionId, version } : {}), ...expiry(expiresIn) });
342
+ }
343
+ catch (err) {
344
+ res.status(500).json({ message: 'Failed to generate download URL', details: err.message });
345
+ }
346
+ });
282
347
  // ─── Per-connection file routes ───────────────────────────────────────────
283
348
  router.get('/projects/:projectId/connections/storage/:connId/upload-url', async (req, res) => {
284
349
  try {
@@ -309,7 +374,7 @@ function storageRouter(config) {
309
374
  const storageKey = `${projectId}/${category}/${fileId}${ext}`;
310
375
  const collection = getRawFilesCollection();
311
376
  if (collection) {
312
- await collection.insertOne({
377
+ const fileDoc = {
313
378
  _id: new mongodb_1.ObjectId(),
314
379
  fileId,
315
380
  projectId,
@@ -320,10 +385,13 @@ function storageRouter(config) {
320
385
  contentType,
321
386
  uploadedBy: auth.userId,
322
387
  createdAt: new Date(),
323
- });
388
+ };
389
+ await collection.insertOne(fileDoc);
390
+ await (0, assetVersions_1.recordFirstVersion)(fileDoc);
324
391
  }
325
- const url = await connAdapter.getPresignedUploadUrl(storageKey, contentType);
326
- res.json({ url, fileId, storageKey, displayName: fileName });
392
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
393
+ const url = await connAdapter.getPresignedUploadUrl(storageKey, contentType, expiresIn);
394
+ res.json({ url, fileId, storageKey, displayName: fileName, ...expiry(expiresIn) });
327
395
  }
328
396
  catch (err) {
329
397
  res.status(500).json({ message: 'Failed to generate upload URL', details: err.message });
@@ -345,7 +413,7 @@ function storageRouter(config) {
345
413
  if (!collection)
346
414
  return res.status(500).json({ message: 'Database not available' });
347
415
  const files = await collection.find(connFilter(projectId, connId, project)).sort({ createdAt: -1 }).limit(12).toArray();
348
- const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter)));
416
+ const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
349
417
  res.json({ data });
350
418
  }
351
419
  catch (err) {
@@ -371,7 +439,7 @@ function storageRouter(config) {
371
439
  if (!collection)
372
440
  return res.status(500).json({ message: 'Database not available' });
373
441
  const files = await collection.find(connFilter(projectId, connId, project, { category })).sort({ createdAt: -1 }).toArray();
374
- const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter)));
442
+ const data = await Promise.all(files.map(f => resolveFileUrl(f, connAdapter, (0, signedUrls_1.signedUrlTtl)(auth, config, 'download'))));
375
443
  res.json({ data });
376
444
  }
377
445
  catch (err) {
@@ -389,7 +457,7 @@ function storageRouter(config) {
389
457
  const project = await Project.findOne({ _id: projectId });
390
458
  if (!project)
391
459
  return res.status(404).json({ message: 'Project not found' });
392
- if (!(0, auth_1.isProjectMember)(project, auth.userId))
460
+ if (!(0, auth_1.canEditProject)(project, auth.userId))
393
461
  return res.status(403).json({ message: 'Unauthorized access' });
394
462
  const collection = getRawFilesCollection();
395
463
  if (!collection)
@@ -406,9 +474,21 @@ function storageRouter(config) {
406
474
  if (!connAdapter.getPresignedUploadUrl) {
407
475
  return res.status(501).json({ message: 'Storage adapter does not support presigned uploads' });
408
476
  }
409
- const url = await connAdapter.getPresignedUploadUrl(fileDoc.storageKey, contentType);
477
+ // The new version goes to the asset's own key, so its URL doesn't change; the current
478
+ // one is archived first, and without a way to archive it nothing is replaced.
479
+ if (!connAdapter.copyFile)
480
+ return res.status(501).json({ message: 'Storage adapter cannot keep versions' });
481
+ let version;
482
+ try {
483
+ version = await (0, assetVersions_1.archiveCurrentVersion)(fileDoc, { fileName, contentType, userId: auth.userId }, connAdapter.copyFile.bind(connAdapter));
484
+ }
485
+ catch (err) {
486
+ return res.status(502).json({ message: 'Failed to archive the current version; nothing was replaced', details: err.message });
487
+ }
488
+ const expiresIn = (0, signedUrls_1.signedUrlTtl)(auth, config, 'upload');
489
+ const url = await connAdapter.getPresignedUploadUrl(fileDoc.storageKey, contentType, expiresIn);
410
490
  await collection.updateOne(connFilter(projectId, connId, project, { fileId }), { $set: { contentType, updatedAt: new Date() } });
411
- res.json({ url, storageKey: fileDoc.storageKey, fileId });
491
+ res.json({ url, storageKey: fileDoc.storageKey, fileId, versionId: version.versionId, version: version.version, ...expiry(expiresIn) });
412
492
  }
413
493
  catch (err) {
414
494
  res.status(500).json({ message: 'Failed to generate replace URL', details: err.message });
@@ -457,8 +537,7 @@ function storageRouter(config) {
457
537
  const fileDoc = await collection.findOne(connFilter(projectId, connId, project, { fileId }));
458
538
  if (!fileDoc)
459
539
  return res.status(404).json({ message: 'File not found' });
460
- if (connAdapter.deleteFile)
461
- await connAdapter.deleteFile(fileDoc.storageKey);
540
+ await deleteAsset(fileDoc, connAdapter);
462
541
  await collection.deleteOne(connFilter(projectId, connId, project, { fileId }));
463
542
  res.json({ message: 'File deleted successfully' });
464
543
  }
@@ -731,11 +810,11 @@ function storageRouter(config) {
731
810
  });
732
811
  return router;
733
812
  }
734
- async function resolveFileUrl(file, adapter) {
813
+ async function resolveFileUrl(file, adapter, expiresIn) {
735
814
  let url = null;
736
815
  if (adapter.getPresignedGetUrl) {
737
816
  try {
738
- url = await adapter.getPresignedGetUrl(file.storageKey);
817
+ url = await adapter.getPresignedGetUrl(file.storageKey, expiresIn);
739
818
  }
740
819
  catch { /* leave null */ }
741
820
  }
@@ -751,3 +830,15 @@ async function resolveFileUrl(file, adapter) {
751
830
  url,
752
831
  };
753
832
  }
833
+ // When a signed URL stops working, for a client to know (only when it's short-lived).
834
+ const expiry = (expiresIn) => expiresIn ? { expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() } : {};
835
+ // An asset's files (every version), its versions and its deliverables. Its record is the
836
+ // caller's to delete.
837
+ async function deleteAsset(fileDoc, adapter) {
838
+ const keys = await (0, assetVersions_1.versionStorageKeys)(fileDoc.projectId, fileDoc.fileId, fileDoc.storageKey);
839
+ if (adapter.deleteFile)
840
+ for (const key of keys)
841
+ await adapter.deleteFile(key);
842
+ await (0, assetVersions_1.deleteVersions)(fileDoc.projectId, fileDoc.fileId);
843
+ await (0, deliverables_1.deleteDeliverablesOf)(fileDoc.projectId, { assetId: fileDoc.fileId });
844
+ }
@@ -45,11 +45,11 @@ class Model {
45
45
  // Handle members filter for SQL projects
46
46
  const userId = processedFilter.members.$elemMatch.userId;
47
47
  const schema = this.schema || 'streamby'; // Default to 'streamby' if schema not provided
48
- const query = `
49
- SELECT p.*, pm."userId" as memberUserId, pm.role as memberRole, pm.status as memberStatus, pm.archived as memberArchived
50
- FROM "${schema}"."projects" p
51
- JOIN "${schema}"."project_members" pm ON p.id = pm."projectId"
52
- WHERE pm."userId" = $1
48
+ const query = `
49
+ SELECT p.*, pm."userId" as memberUserId, pm.role as memberRole, pm.status as memberStatus, pm.archived as memberArchived
50
+ FROM "${schema}"."projects" p
51
+ JOIN "${schema}"."project_members" pm ON p.id = pm."projectId"
52
+ WHERE pm."userId" = $1
53
53
  `;
54
54
  const result = await connection.query(query, [userId]);
55
55
  sqlResults = result.rows.map(row => ({
@@ -219,10 +219,10 @@ class Model {
219
219
  else {
220
220
  const matchKey = value.id !== undefined ? 'id' : Object.keys(value)[0];
221
221
  const matchVal = String(value[matchKey]);
222
- await connection.query(`UPDATE ${tbl} SET "${field}" = (
223
- SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)
224
- FROM jsonb_array_elements("${field}") elem
225
- WHERE elem->>'${matchKey}' != $1
222
+ await connection.query(`UPDATE ${tbl} SET "${field}" = (
223
+ SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)
224
+ FROM jsonb_array_elements("${field}") elem
225
+ WHERE elem->>'${matchKey}' != $1
226
226
  ) WHERE id = $2`, [matchVal, id]);
227
227
  }
228
228
  }
@@ -0,0 +1,18 @@
1
+ import { StreamByConfig, ApiConnection, ApiConnectionMethod } from '../types';
2
+ export declare function addApiConnection(config: StreamByConfig, projectId: string, data: {
3
+ name: string;
4
+ apiUrl: string;
5
+ method: ApiConnectionMethod;
6
+ prefix?: string;
7
+ description?: string;
8
+ credentialId?: string;
9
+ }): Promise<ApiConnection>;
10
+ export declare function updateApiConnection(config: StreamByConfig, projectId: string, connectionId: string, data: {
11
+ name?: string;
12
+ apiUrl?: string;
13
+ method?: ApiConnectionMethod;
14
+ prefix?: string;
15
+ description?: string;
16
+ credentialId?: string;
17
+ }): Promise<ApiConnection>;
18
+ export declare function deleteApiConnection(config: StreamByConfig, projectId: string, connectionId: string): Promise<void>;