streamby-core 0.36.0 → 0.38.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 (46) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +141 -44
  3. package/dist/adapters/database/connectionManager.js +24 -11
  4. package/dist/adapters/storage/s3.d.ts +3 -2
  5. package/dist/adapters/storage/s3.js +18 -5
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.js +4 -1
  8. package/dist/mcp/server.d.ts +4 -3
  9. package/dist/mcp/server.js +25 -10
  10. package/dist/mcp/tools.d.ts +7 -2
  11. package/dist/mcp/tools.js +187 -35
  12. package/dist/mcp/workflowNodes.d.ts +24 -0
  13. package/dist/mcp/workflowNodes.js +48 -0
  14. package/dist/middleware/createRouter.js +2 -0
  15. package/dist/middleware/routes/apiToken.js +77 -14
  16. package/dist/middleware/routes/deliverable.d.ts +3 -0
  17. package/dist/middleware/routes/deliverable.js +131 -0
  18. package/dist/middleware/routes/export.js +2 -0
  19. package/dist/middleware/routes/member.js +8 -0
  20. package/dist/middleware/routes/production.js +60 -67
  21. package/dist/middleware/routes/project.js +15 -3
  22. package/dist/middleware/routes/storage.js +115 -24
  23. package/dist/middleware/routes/subscription.js +3 -0
  24. package/dist/services/apiTokenPermissions.d.ts +16 -0
  25. package/dist/services/apiTokenPermissions.js +102 -0
  26. package/dist/services/apiTokens.d.ts +38 -14
  27. package/dist/services/apiTokens.js +134 -40
  28. package/dist/services/assetVersions.d.ts +48 -0
  29. package/dist/services/assetVersions.js +84 -0
  30. package/dist/services/auth.d.ts +4 -1
  31. package/dist/services/auth.js +24 -18
  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/production.d.ts +2 -0
  36. package/dist/services/production.js +13 -0
  37. package/dist/services/productionFields.d.ts +16 -0
  38. package/dist/services/productionFields.js +80 -0
  39. package/dist/services/signedUrls.d.ts +6 -0
  40. package/dist/services/signedUrls.js +9 -0
  41. package/dist/types/index.d.ts +12 -4
  42. package/dist/utils/auth.d.ts +12 -1
  43. package/dist/utils/auth.js +23 -2
  44. package/dist/utils/nosqlDb.d.ts +2 -0
  45. package/dist/utils/nosqlDb.js +11 -0
  46. package/package.json +1 -1
@@ -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
+ }
@@ -18,6 +18,9 @@ const ensureSubscription = async (req, res, next) => {
18
18
  const auth = req.auth;
19
19
  if (!auth)
20
20
  return next();
21
+ // A project's token is no user: it has no plan of its own.
22
+ if (auth.apiToken?.kind === 'project')
23
+ return next();
21
24
  const plan = await resolveSubscription(auth);
22
25
  req.subscription = plan;
23
26
  next();
@@ -0,0 +1,16 @@
1
+ /** Permissions on a project, for the projects a token reaches. */
2
+ export declare const PROJECT_PERMISSIONS: readonly ["project:read", "project:update", "project:delete", "members:read", "members:manage", "assets:read", "assets:write", "assets:delete", "production:read", "production:write", "production:delete", "workflow:read", "workflow:write", "exports:read", "exports:write", "rights:read", "rights:write", "jobs:read", "jobs:run"];
3
+ /** Permissions on the account: only a user's token has them. */
4
+ export declare const ACCOUNT_PERMISSIONS: readonly ["projects:create"];
5
+ export type ProjectPermission = typeof PROJECT_PERMISSIONS[number];
6
+ export type AccountPermission = typeof ACCOUNT_PERMISSIONS[number];
7
+ export type ApiTokenPermission = ProjectPermission | AccountPermission;
8
+ export declare const isProjectPermission: (value: unknown) => value is ProjectPermission;
9
+ export declare const isAccountPermission: (value: unknown) => value is AccountPermission;
10
+ /** What a route needs of a token: a permission, or only being a token (`null`). */
11
+ export interface RouteRule {
12
+ permission: ApiTokenPermission | null;
13
+ userTokenOnly?: boolean;
14
+ }
15
+ /** The rule for a request made with an API token, or undefined: not reachable with one. */
16
+ export declare function routeRuleFor(method: string, path: string): RouteRule | undefined;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ // What an API token may do: a list of permissions, each a resource and an action. A request
3
+ // made with a token goes through only when its route's permission is among the token's (see
4
+ // enforceApiTokenScope), and a route without one isn't reachable with a token at all.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isAccountPermission = exports.isProjectPermission = exports.ACCOUNT_PERMISSIONS = exports.PROJECT_PERMISSIONS = void 0;
7
+ exports.routeRuleFor = routeRuleFor;
8
+ /** Permissions on a project, for the projects a token reaches. */
9
+ exports.PROJECT_PERMISSIONS = [
10
+ 'project:read', 'project:update', 'project:delete',
11
+ 'members:read', 'members:manage',
12
+ 'assets:read', 'assets:write', 'assets:delete',
13
+ 'production:read', 'production:write', 'production:delete',
14
+ 'workflow:read', 'workflow:write',
15
+ 'exports:read', 'exports:write',
16
+ 'rights:read', 'rights:write',
17
+ 'jobs:read', 'jobs:run',
18
+ ];
19
+ /** Permissions on the account: only a user's token has them. */
20
+ exports.ACCOUNT_PERMISSIONS = ['projects:create'];
21
+ const isProjectPermission = (value) => exports.PROJECT_PERMISSIONS.includes(value);
22
+ exports.isProjectPermission = isProjectPermission;
23
+ const isAccountPermission = (value) => exports.ACCOUNT_PERMISSIONS.includes(value);
24
+ exports.isAccountPermission = isAccountPermission;
25
+ const READ = ['GET', 'HEAD', 'OPTIONS'];
26
+ const methodsOf = (methods) => methods === 'read' ? READ : methods === 'write' ? ['POST', 'PUT', 'PATCH'] : methods === 'delete' ? ['DELETE'] : methods;
27
+ // A project's own path (not /projects/create nor /projects/explore).
28
+ const P = '^/projects/(?!(?:create|explore)/?$)[^/]+';
29
+ const rule = (methods, path, permission, extra = {}) => ({ methods: methodsOf(methods), pattern: new RegExp(`${path}/?$`), permission, ...extra });
30
+ // The first rule that matches a request decides it; a request no rule matches is refused.
31
+ // Connections, integrations and allowed origins are the project's settings (project:*);
32
+ // reviews, QC reports and dependency graphs are of its assets; pipelines are workflow.
33
+ const RULES = [
34
+ // Outside a project.
35
+ rule('read', '^/auth', null),
36
+ rule('read', '^/projects', null),
37
+ rule(['POST'], '^/projects/create', 'projects:create'),
38
+ rule('read', '^/user/subscription', null, { userTokenOnly: true }),
39
+ rule('read', '^/users/search', 'members:manage'),
40
+ rule('read', '^/jobs/[^/]+', 'jobs:read'),
41
+ // The project itself and its settings.
42
+ rule('read', `${P}(/preview)?`, 'project:read'),
43
+ rule(['PATCH'], P, 'project:update'),
44
+ rule('delete', P, 'project:delete'),
45
+ rule(['PATCH'], `${P}/(archive|unarchive)`, 'project:update', { userTokenOnly: true }),
46
+ rule(['POST'], `${P}/image`, 'project:update'),
47
+ rule(['PATCH'], `${P}/origins`, 'project:update'),
48
+ rule(['POST'], `${P}/integrations`, 'project:update'),
49
+ rule('delete', `${P}/integrations/[^/]+`, 'project:update'),
50
+ rule('read', '^/upload-project-image-url/[^/]+', 'project:update'),
51
+ rule('delete', '^/delete-project-image-url/[^/]+', 'project:update'),
52
+ // Members.
53
+ rule('read', `${P}/members`, 'members:read'),
54
+ rule(['POST'], `${P}/members`, 'members:manage'),
55
+ rule(['PATCH'], `${P}/members/[^/]+(/approve)?`, 'members:manage'),
56
+ rule('delete', `${P}/members/[^/]+`, 'members:manage'),
57
+ // Assets: the built-in storage, and a storage connection's files and folders.
58
+ rule('read', `${P}/(storage|connections/storage/[^/]+)/(.+/)?(upload-url|replace-url)`, 'assets:write'),
59
+ rule('read', `${P}/storage/.+`, 'assets:read'),
60
+ rule(['PATCH'], `${P}/storage/files/[^/]+`, 'assets:write'),
61
+ rule('delete', `${P}/storage/files/[^/]+`, 'assets:delete'),
62
+ rule('read', `${P}/connections/storage/[^/]+/(files|folders)(/.+)?`, 'assets:read'),
63
+ rule(['POST', 'PATCH'], `${P}/connections/storage/[^/]+/(files|folders)(/.+)?`, 'assets:write'),
64
+ rule('delete', `${P}/connections/storage/[^/]+/(files|folders)/[^/]+`, 'assets:delete'),
65
+ rule('read', `${P}/assets/[^/]+/(metadata|versions|qc-report|dependency-graph|download-url)`, 'assets:read'),
66
+ rule(['PATCH'], `${P}/assets/[^/]+/metadata`, 'assets:write'),
67
+ rule(['POST'], `${P}/assets/[^/]+/qc-report`, 'assets:write'),
68
+ rule('read', `${P}/(reviews(/[^/]+)?|assets/[^/]+/versions/[^/]+/annotations)`, 'assets:read'),
69
+ rule(['POST', 'PATCH'], `${P}/(reviews(/[^/]+/decision)?|assets/[^/]+/versions/[^/]+/annotations(/[^/]+/resolve)?)`, 'assets:write'),
70
+ // Deliverables: an asset (or a version) delivered for a shot or an export.
71
+ rule('read', `${P}/deliverables`, 'assets:read'),
72
+ rule(['POST'], `${P}/deliverables`, 'assets:write'),
73
+ rule('delete', `${P}/deliverables/[^/]+`, 'assets:write'),
74
+ // Rights.
75
+ rule('read', `${P}/assets/[^/]+/rights`, 'rights:read'),
76
+ rule(['PUT'], `${P}/assets/[^/]+/rights`, 'rights:write'),
77
+ // Connections (API, database, storage, distribution, render farm): the project's settings.
78
+ rule('read', `${P}/(connections|get-connection|distribution-connections|render-farm-connections)(/.+)?`, 'project:read'),
79
+ rule('write', `${P}/(connections|distribution-connections|render-farm-connections)(/.+)?`, 'project:update'),
80
+ rule('delete', `${P}/(connections|distribution-connections|render-farm-connections)/.+`, 'project:update'),
81
+ // Production.
82
+ rule('read', `${P}/production/.+`, 'production:read'),
83
+ rule('write', `${P}/production/.+`, 'production:write'),
84
+ rule('delete', `${P}/production/.+`, 'production:delete'),
85
+ // Workflow and pipelines.
86
+ rule('read', `${P}/(workflow|pipelines(/[^/]+)?)`, 'workflow:read'),
87
+ rule(['PATCH'], `${P}/workflow`, 'workflow:write'),
88
+ rule(['POST'], `${P}/pipelines`, 'workflow:write'),
89
+ rule(['PATCH', 'DELETE'], `${P}/pipelines/[^/]+`, 'workflow:write'),
90
+ // Exports.
91
+ rule('read', `${P}/exports/[^/]+`, 'exports:read'),
92
+ rule(['POST'], `${P}/exports`, 'exports:write'),
93
+ rule(['PATCH', 'DELETE'], `${P}/exports/[^/]+`, 'exports:write'),
94
+ // Jobs: transcoding, rendering, AI, publishing a deliverable.
95
+ rule(['POST'], `${P}/(jobs/[^/]+|pipeline-suggest|deliverables/[^/]+/publish)`, 'jobs:run'),
96
+ ];
97
+ /** The rule for a request made with an API token, or undefined: not reachable with one. */
98
+ function routeRuleFor(method, path) {
99
+ const upper = method.toUpperCase();
100
+ const found = RULES.find(r => r.methods.includes(upper) && r.pattern.test(path));
101
+ return found && { permission: found.permission, userTokenOnly: found.userTokenOnly };
102
+ }
@@ -1,21 +1,28 @@
1
- import { ApiTokenAccess, ApiTokenScope, StreamByConfig } from '../types';
1
+ import { ApiTokenKind, ApiTokenScope, StreamByConfig } from '../types';
2
+ import { ApiTokenPermission } from './apiTokenPermissions';
2
3
  export declare const API_TOKEN_PREFIX = "sbt_";
3
4
  export declare const API_TOKEN_EXPIRY_DAYS: readonly [7, 30, 90, 365];
4
5
  export interface ApiTokenRecord {
5
6
  id: string;
7
+ kind: ApiTokenKind;
6
8
  userId: string;
9
+ createdByName: string | null;
7
10
  name: string;
8
11
  prefix: string;
9
12
  hash: string;
10
- access: ApiTokenAccess;
13
+ permissions: ApiTokenPermission[];
11
14
  projectIds: string[];
12
15
  createdAt: Date;
13
16
  expiresAt: Date | null;
14
17
  lastUsedAt: Date | null;
15
18
  }
16
- /** What a token's owner sees of it. */
17
- export type ApiTokenInfo = Omit<ApiTokenRecord, 'hash' | 'userId' | 'projectIds'> & {
19
+ /** What's shown of a token: never its hash. */
20
+ export type ApiTokenInfo = Omit<ApiTokenRecord, 'hash' | 'userId' | 'projectIds' | 'createdByName'> & {
18
21
  projectIds: string[] | '*';
22
+ createdBy: {
23
+ userId: string;
24
+ username: string | null;
25
+ };
19
26
  };
20
27
  export declare class ApiTokenError extends Error {
21
28
  status: number;
@@ -23,25 +30,42 @@ export declare class ApiTokenError extends Error {
23
30
  }
24
31
  export interface CreateApiTokenInput {
25
32
  name: string;
26
- access: ApiTokenAccess;
27
- projectIds: string[] | '*';
33
+ permissions: ApiTokenPermission[];
34
+ projectIds?: string[] | '*';
28
35
  expiresInDays: number | null;
29
36
  }
30
- /** A new token for `userId`: the token itself, shown only now, and what's kept of it. */
31
- export declare function createApiToken(userId: string, input: CreateApiTokenInput, config: StreamByConfig): Promise<{
37
+ export interface ApiTokenOwner {
38
+ kind: ApiTokenKind;
39
+ userId: string;
40
+ username?: string | null;
41
+ projectId?: string;
42
+ }
43
+ /** A new token: the token itself, shown only now, and what's kept of it. */
44
+ export declare function createApiToken(owner: ApiTokenOwner, input: CreateApiTokenInput, config: StreamByConfig): Promise<{
32
45
  token: string;
33
46
  info: ApiTokenInfo;
34
47
  }>;
35
- /** The tokens `userId` has. */
36
- export declare function listApiTokens(userId: string): Promise<ApiTokenInfo[]>;
37
- /** Revokes one of `userId`'s tokens: it stops working at once. Whether there was one. */
38
- export declare function revokeApiToken(userId: string, tokenId: string): Promise<boolean>;
48
+ /** A user's own tokens. */
49
+ export declare function listUserApiTokens(userId: string): Promise<ApiTokenInfo[]>;
50
+ /** A project's tokens. */
51
+ export declare function listProjectApiTokens(projectId: string): Promise<ApiTokenInfo[]>;
52
+ /** Revokes a user's own token: it stops working at once. Whether there was one. */
53
+ export declare function revokeUserApiToken(userId: string, tokenId: string): Promise<boolean>;
54
+ /** Revokes a project's token. Whether there was one. */
55
+ export declare function revokeProjectApiToken(projectId: string, tokenId: string): Promise<boolean>;
56
+ /** A project's tokens that `userId` made (to tell its admins they keep working once they leave). */
57
+ export declare function projectApiTokensMadeBy(projectId: string, userId: string): Promise<ApiTokenInfo[]>;
58
+ /** Ends a deleted project's tokens. */
59
+ export declare function deleteProjectApiTokens(projectId: string): Promise<void>;
60
+ /** A project a user's token made: it reaches it from now on (one reaching every project already does). */
61
+ export declare function addProjectToApiToken(tokenId: string, projectId: string): Promise<void>;
39
62
  export interface ResolvedApiToken {
40
63
  userId: string;
64
+ name: string;
41
65
  scope: ApiTokenScope;
42
66
  }
43
67
  /**
44
- * The token's owner and scope, for the host's authProvider; null when it isn't a token, isn't
45
- * known (revoked), or has expired. Marks when it was last used.
68
+ * The token's request: who it is and what it may do, for the host's authProvider; null when
69
+ * it isn't a token, isn't known (revoked), or has expired. Marks when it was last used.
46
70
  */
47
71
  export declare function resolveApiToken(token: unknown): Promise<ResolvedApiToken | null>;