streamby-core 0.33.0 → 0.35.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 (54) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/LICENSE +661 -0
  3. package/README.md +111 -11
  4. package/dist/adapters/database/connectionManager.js +18 -0
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.js +4 -1
  7. package/dist/middleware/createRouter.js +19 -2
  8. package/dist/middleware/routes/ai.js +3 -3
  9. package/dist/middleware/routes/{credential.d.ts → apiToken.d.ts} +1 -1
  10. package/dist/middleware/routes/apiToken.js +58 -0
  11. package/dist/middleware/routes/connection.js +3 -12
  12. package/dist/middleware/routes/dbConnection.js +7 -26
  13. package/dist/middleware/routes/distribution.js +12 -5
  14. package/dist/middleware/routes/export.js +5 -4
  15. package/dist/middleware/routes/media.js +27 -10
  16. package/dist/middleware/routes/member.js +3 -12
  17. package/dist/middleware/routes/pipeline.js +3 -3
  18. package/dist/middleware/routes/production.js +9 -9
  19. package/dist/middleware/routes/project.js +12 -25
  20. package/dist/middleware/routes/renderFarm.js +13 -6
  21. package/dist/middleware/routes/review.js +4 -4
  22. package/dist/middleware/routes/rights.js +1 -1
  23. package/dist/middleware/routes/storage.js +21 -15
  24. package/dist/middleware/routes/storageConnection.js +2 -8
  25. package/dist/middleware/routes/workflow.js +1 -1
  26. package/dist/services/apiTokens.d.ts +47 -0
  27. package/dist/services/apiTokens.js +110 -0
  28. package/dist/services/auth.d.ts +5 -3
  29. package/dist/services/auth.js +49 -11
  30. package/dist/services/connectionResolver.js +2 -2
  31. package/dist/services/executor.d.ts +4 -1
  32. package/dist/services/executor.js +29 -11
  33. package/dist/services/reencryptCredentials.d.ts +5 -0
  34. package/dist/services/reencryptCredentials.js +74 -0
  35. package/dist/services/userIntegration.d.ts +2 -2
  36. package/dist/services/userIntegration.js +7 -1
  37. package/dist/services/wsHub.js +6 -0
  38. package/dist/types/index.d.ts +9 -0
  39. package/dist/utils/auth.d.ts +15 -1
  40. package/dist/utils/auth.js +21 -6
  41. package/dist/utils/encryption.d.ts +6 -1
  42. package/dist/utils/encryption.js +80 -27
  43. package/dist/utils/serviceEndpoints.d.ts +2 -0
  44. package/dist/utils/serviceEndpoints.js +26 -0
  45. package/package.json +4 -3
  46. package/dist/middleware/routes/apiConnection.d.ts +0 -3
  47. package/dist/middleware/routes/apiConnection.js +0 -100
  48. package/dist/middleware/routes/credential.js +0 -80
  49. package/dist/services/apiConnection.d.ts +0 -18
  50. package/dist/services/apiConnection.js +0 -65
  51. package/dist/services/credential.d.ts +0 -8
  52. package/dist/services/credential.js +0 -70
  53. package/dist/services/pipeline.d.ts +0 -2
  54. package/dist/services/pipeline.js +0 -292
@@ -40,9 +40,6 @@ function storageConnectionRouter(config) {
40
40
  router.post('/projects/:id/connections/storage', async (req, res) => {
41
41
  try {
42
42
  const auth = req.auth;
43
- if (auth.role !== 'admin' && auth.role !== 'editor') {
44
- return res.status(403).json({ message: 'Permission denied' });
45
- }
46
43
  const { name, type, credential, integrationId, description } = req.body;
47
44
  if (!name || !type) {
48
45
  return res.status(400).json({ message: 'name and type are required' });
@@ -59,7 +56,7 @@ function storageConnectionRouter(config) {
59
56
  const project = await Project.findOne({ _id: req.params.id });
60
57
  if (!project)
61
58
  return res.status(404).json({ message: 'Project not found' });
62
- if (!(0, auth_1.isProjectMember)(project, auth.userId))
59
+ if (!(0, auth_1.canEditProject)(project, auth.userId))
63
60
  return res.status(403).json({ message: 'Unauthorized project access' });
64
61
  let encryptedCredential;
65
62
  if (integrationId) {
@@ -98,13 +95,10 @@ function storageConnectionRouter(config) {
98
95
  try {
99
96
  const { connId } = req.params;
100
97
  const auth = req.auth;
101
- if (auth.role !== 'admin' && auth.role !== 'editor') {
102
- return res.status(403).json({ message: 'Permission denied' });
103
- }
104
98
  const project = await Project.findOne({ _id: req.params.id });
105
99
  if (!project)
106
100
  return res.status(404).json({ message: 'Project not found' });
107
- if (!(0, auth_1.isProjectMember)(project, auth.userId))
101
+ if (!(0, auth_1.canEditProject)(project, auth.userId))
108
102
  return res.status(403).json({ message: 'Unauthorized project access' });
109
103
  const conn = project.storageConnections?.find((c) => c.id === connId);
110
104
  if (!conn)
@@ -38,7 +38,7 @@ function workflowRouter(config) {
38
38
  return res.status(401).json({ message: 'Unauthorized' });
39
39
  const projectId = req.params.id;
40
40
  const project = await Project.findOne({ _id: projectId });
41
- if (!project || !(0, auth_1.isProjectMember)(project, auth.userId)) {
41
+ if (!project || !(0, auth_1.canEditProject)(project, auth.userId)) {
42
42
  return res.status(403).json({ message: 'Unauthorized project access' });
43
43
  }
44
44
  const now = new Date();
@@ -0,0 +1,47 @@
1
+ import { ApiTokenAccess, ApiTokenScope, StreamByConfig } from '../types';
2
+ export declare const API_TOKEN_PREFIX = "sbt_";
3
+ export declare const API_TOKEN_EXPIRY_DAYS: readonly [7, 30, 90, 365];
4
+ export interface ApiTokenRecord {
5
+ id: string;
6
+ userId: string;
7
+ name: string;
8
+ prefix: string;
9
+ hash: string;
10
+ access: ApiTokenAccess;
11
+ projectIds: string[];
12
+ createdAt: Date;
13
+ expiresAt: Date | null;
14
+ lastUsedAt: Date | null;
15
+ }
16
+ /** What a token's owner sees of it. */
17
+ export type ApiTokenInfo = Omit<ApiTokenRecord, 'hash' | 'userId' | 'projectIds'> & {
18
+ projectIds: string[] | '*';
19
+ };
20
+ export declare class ApiTokenError extends Error {
21
+ status: number;
22
+ constructor(message: string, status?: number);
23
+ }
24
+ export interface CreateApiTokenInput {
25
+ name: string;
26
+ access: ApiTokenAccess;
27
+ projectIds: string[] | '*';
28
+ expiresInDays: number | null;
29
+ }
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<{
32
+ token: string;
33
+ info: ApiTokenInfo;
34
+ }>;
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>;
39
+ export interface ResolvedApiToken {
40
+ userId: string;
41
+ scope: ApiTokenScope;
42
+ }
43
+ /**
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.
46
+ */
47
+ export declare function resolveApiToken(token: unknown): Promise<ResolvedApiToken | null>;
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ApiTokenError = exports.API_TOKEN_EXPIRY_DAYS = exports.API_TOKEN_PREFIX = void 0;
7
+ exports.createApiToken = createApiToken;
8
+ exports.listApiTokens = listApiTokens;
9
+ exports.revokeApiToken = revokeApiToken;
10
+ exports.resolveApiToken = resolveApiToken;
11
+ const crypto_1 = __importDefault(require("crypto"));
12
+ const manager_1 = require("../models/manager");
13
+ // API tokens: how an agent or a service works with StreamBy as a user, without their browser
14
+ // session. A token is "sbt_" and 32 random bytes; only its hash is stored, with a prefix to
15
+ // tell it apart in a list. It's sent as `Authorization: Bearer sbt_...` and the host's
16
+ // authProvider resolves it (resolveApiToken), so the request is its owner's -- limited by the
17
+ // token's scope (read or write, which projects), and never more than its owner can do: the
18
+ // owner's role in each project still decides.
19
+ exports.API_TOKEN_PREFIX = 'sbt_';
20
+ const PREFIX_SHOWN = exports.API_TOKEN_PREFIX.length + 8;
21
+ exports.API_TOKEN_EXPIRY_DAYS = [7, 30, 90, 365];
22
+ const ALL_PROJECTS = '*';
23
+ class ApiTokenError extends Error {
24
+ constructor(message, status = 400) {
25
+ super(message);
26
+ this.status = status;
27
+ }
28
+ }
29
+ exports.ApiTokenError = ApiTokenError;
30
+ const hashOf = (token) => crypto_1.default.createHash('sha256').update(token).digest('hex');
31
+ const infoOf = ({ hash: _hash, userId: _userId, ...info }) => ({
32
+ ...info,
33
+ projectIds: parseProjectIds(info.projectIds),
34
+ });
35
+ // As stored (a list, read back from SQL JSON as a list or its text): ['*'] is every project.
36
+ const parseProjectIds = (value) => {
37
+ if (typeof value === 'string') {
38
+ try {
39
+ return parseProjectIds(JSON.parse(value));
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
45
+ if (!Array.isArray(value))
46
+ return [];
47
+ const ids = value.map(String);
48
+ return ids.includes(ALL_PROJECTS) ? '*' : ids;
49
+ };
50
+ const mainDbType = (config) => config.databases?.find(db => db.main)?.type ?? 'nosql';
51
+ /** A new token for `userId`: the token itself, shown only now, and what's kept of it. */
52
+ async function createApiToken(userId, input, config) {
53
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
54
+ if (!name)
55
+ throw new ApiTokenError('A token needs a name.');
56
+ if (input.access !== 'read' && input.access !== 'write')
57
+ throw new ApiTokenError("access must be 'read' or 'write'.");
58
+ if (input.projectIds !== '*' && (!Array.isArray(input.projectIds) || !input.projectIds.length || !input.projectIds.every(id => typeof id === 'string' && id && id !== ALL_PROJECTS))) {
59
+ throw new ApiTokenError("projectIds must be '*' or a list of project ids.");
60
+ }
61
+ if (input.expiresInDays !== null && !exports.API_TOKEN_EXPIRY_DAYS.includes(input.expiresInDays)) {
62
+ throw new ApiTokenError(`expiresInDays must be one of ${exports.API_TOKEN_EXPIRY_DAYS.join(', ')}, or null.`);
63
+ }
64
+ const token = exports.API_TOKEN_PREFIX + crypto_1.default.randomBytes(32).toString('base64url');
65
+ const now = new Date();
66
+ const record = {
67
+ id: crypto_1.default.randomUUID(),
68
+ userId,
69
+ name,
70
+ prefix: token.slice(0, PREFIX_SHOWN),
71
+ hash: hashOf(token),
72
+ access: input.access,
73
+ projectIds: input.projectIds === '*' ? [ALL_PROJECTS] : [...new Set(input.projectIds)],
74
+ createdAt: now,
75
+ expiresAt: input.expiresInDays === null ? null : new Date(now.getTime() + input.expiresInDays * 86400000),
76
+ lastUsedAt: null,
77
+ };
78
+ await (0, manager_1.getModel)('api_tokens').create({ ...record, dbType: mainDbType(config) });
79
+ return { token, info: infoOf(record) };
80
+ }
81
+ /** The tokens `userId` has. */
82
+ async function listApiTokens(userId) {
83
+ const records = (await (0, manager_1.getModel)('api_tokens').find({ userId }));
84
+ return records
85
+ .map(infoOf)
86
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
87
+ }
88
+ /** Revokes one of `userId`'s tokens: it stops working at once. Whether there was one. */
89
+ async function revokeApiToken(userId, tokenId) {
90
+ return (await (0, manager_1.getModel)('api_tokens').delete({ id: tokenId, userId })) > 0;
91
+ }
92
+ /**
93
+ * The token's owner and scope, for the host's authProvider; null when it isn't a token, isn't
94
+ * known (revoked), or has expired. Marks when it was last used.
95
+ */
96
+ async function resolveApiToken(token) {
97
+ if (typeof token !== 'string' || !token.startsWith(exports.API_TOKEN_PREFIX))
98
+ return null;
99
+ const ApiTokens = (0, manager_1.getModel)('api_tokens');
100
+ const record = (await ApiTokens.findOne({ hash: hashOf(token) }));
101
+ if (!record)
102
+ return null;
103
+ if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now())
104
+ return null;
105
+ ApiTokens.update({ id: record.id }, { lastUsedAt: new Date() }).catch(() => { });
106
+ return {
107
+ userId: record.userId,
108
+ scope: { tokenId: record.id, access: record.access, projectIds: parseProjectIds(record.projectIds) },
109
+ };
110
+ }
@@ -1,5 +1,7 @@
1
- import { Auth } from '../types';
2
1
  import { Request, Response, NextFunction } from 'express';
3
- import { StreamByConfig } from '../types';
2
+ import { Auth, StreamByConfig } from '../types';
3
+ export declare const isPublicRoute: (method: string, path: string) => boolean;
4
+ /** Whether a token's scope reaches a project. */
5
+ export declare const scopeReachesProject: (scope: Auth["apiToken"], projectId: string) => boolean;
6
+ export declare const enforceApiTokenScope: (req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
4
7
  export declare const authenticate: (config: StreamByConfig) => (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
5
- export declare function checkRole(auth: Auth, required: 'viewer' | 'editor' | 'admin'): void;
@@ -1,9 +1,55 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.authenticate = void 0;
4
- exports.checkRole = checkRole;
3
+ exports.authenticate = exports.enforceApiTokenScope = exports.scopeReachesProject = exports.isPublicRoute = void 0;
4
+ // The one route open without a session: a project's public export, read
5
+ // (GET /:projectId/export/:exportName, see routes/export). Exactly that path -- not any path
6
+ // that has an "export" segment somewhere.
7
+ const PUBLIC_EXPORT = /^\/[^/]+\/export\/[^/]+\/?$/;
8
+ const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'];
9
+ const isPublicRoute = (method, path) => READ_METHODS.includes(method.toUpperCase()) && PUBLIC_EXPORT.test(path);
10
+ exports.isPublicRoute = isPublicRoute;
11
+ // ── API tokens ──
12
+ // A request made with an API token reaches only what the token's scope does: no writes with a
13
+ // read token, no project outside its projects (as if it didn't exist), no managing tokens, and
14
+ // of the routes outside a project only the few below. Its owner's role in each project still
15
+ // decides on top (the routes' own checks).
16
+ // The project a route acts on, by its path.
17
+ const PROJECT_PATHS = [/^\/projects\/([^/]+)/, /^\/(?:upload|delete)-project-image-url\/([^/]+)/];
18
+ const NOT_A_PROJECT = new Set(['create', 'explore']);
19
+ const projectIdOf = (path) => {
20
+ for (const pattern of PROJECT_PATHS) {
21
+ const id = pattern.exec(path)?.[1];
22
+ if (id && !NOT_A_PROJECT.has(id))
23
+ return decodeURIComponent(id);
24
+ }
25
+ return undefined;
26
+ };
27
+ // Outside a project, a token reaches only these reads (each limited to its scope there).
28
+ const OUTSIDE_A_PROJECT = [/^\/auth\/?$/, /^\/projects\/?$/, /^\/jobs\/[^/]+\/?$/, /^\/user\/subscription\/?$/, /^\/users\/search\/?$/];
29
+ /** Whether a token's scope reaches a project. */
30
+ const scopeReachesProject = (scope, projectId) => !scope || scope.projectIds === '*' || scope.projectIds.includes(projectId);
31
+ exports.scopeReachesProject = scopeReachesProject;
32
+ const enforceApiTokenScope = (req, res, next) => {
33
+ const scope = req.auth?.apiToken;
34
+ if (!scope)
35
+ return next();
36
+ if (/^\/user\/api-tokens(\/|$)/.test(req.path)) {
37
+ return res.status(403).json({ error: 'API tokens are managed with a session, not with a token' });
38
+ }
39
+ if (!READ_METHODS.includes(req.method.toUpperCase()) && scope.access !== 'write') {
40
+ return res.status(403).json({ error: 'This API token can only read' });
41
+ }
42
+ const projectId = projectIdOf(req.path);
43
+ if (projectId) {
44
+ return (0, exports.scopeReachesProject)(scope, projectId) ? next() : res.status(404).json({ message: 'Project not found' });
45
+ }
46
+ if (READ_METHODS.includes(req.method.toUpperCase()) && OUTSIDE_A_PROJECT.some(route => route.test(req.path)))
47
+ return next();
48
+ return res.status(403).json({ error: 'Not available with an API token' });
49
+ };
50
+ exports.enforceApiTokenScope = enforceApiTokenScope;
5
51
  const authenticate = (config) => async (req, res, next) => {
6
- if (req.path.includes('/export/')) {
52
+ if ((0, exports.isPublicRoute)(req.method, req.path)) {
7
53
  return next();
8
54
  }
9
55
  try {
@@ -20,11 +66,3 @@ const authenticate = (config) => async (req, res, next) => {
20
66
  }
21
67
  };
22
68
  exports.authenticate = authenticate;
23
- function checkRole(auth, required) {
24
- const roles = ['viewer', 'editor', 'admin'];
25
- const userIdx = roles.indexOf(auth.role);
26
- const requiredIdx = roles.indexOf(required);
27
- if (userIdx < requiredIdx) {
28
- throw new Error(`Insufficient role: requires ${required}, found ${auth.role}`);
29
- }
30
- }
@@ -52,7 +52,7 @@ async function resolveDbConnectionClient(project, connId, config, auth) {
52
52
  if (conn.source === 'integration') {
53
53
  if (!conn.integrationId)
54
54
  return { error: 'Connection is missing its integrationId', status: 500 };
55
- const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId, config);
55
+ const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId, config, project);
56
56
  if (!resolved)
57
57
  return { error: 'Integration not found', status: 400 };
58
58
  connectionString = resolved.credential;
@@ -109,7 +109,7 @@ async function resolveStorageAdapter(project, connId, config, auth) {
109
109
  if (!conn.integrationId)
110
110
  return { error: 'Connection is missing its integrationId', status: 500 };
111
111
  try {
112
- const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId, config);
112
+ const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId, config, project);
113
113
  if (!resolved)
114
114
  return { error: 'Integration not found', status: 400 };
115
115
  return new s3_1.S3Adapter(resolved.credential);
@@ -4,4 +4,7 @@ export declare class NodeExecutionError extends Error {
4
4
  nodeType: string;
5
5
  constructor(nodeId: string, nodeType: string, message: string);
6
6
  }
7
- export declare function executeExport(nodeSchema: NodeSchema, project: any, config: StreamByConfig): Promise<any>;
7
+ export interface ExecuteExportOptions {
8
+ public?: boolean;
9
+ }
10
+ export declare function executeExport(nodeSchema: NodeSchema, project: any, config: StreamByConfig, { public: isPublic }?: ExecuteExportOptions): Promise<any>;
@@ -12,6 +12,7 @@ const mediaProcessor_1 = require("./mediaProcessor");
12
12
  const vfxProcessor_1 = require("./vfxProcessor");
13
13
  const aiProcessor_1 = require("./aiProcessor");
14
14
  const storage_1 = require("../providers/storage");
15
+ const serviceEndpoints_1 = require("../utils/serviceEndpoints");
15
16
  function applyFilterConfig(payload, config) {
16
17
  const isArr = Array.isArray(payload);
17
18
  let r = isArr ? [...payload] : payload;
@@ -87,13 +88,19 @@ class NodeExecutionError extends Error {
87
88
  }
88
89
  }
89
90
  exports.NodeExecutionError = NodeExecutionError;
90
- // Export execution is public (gated by allowedOrigin, not user identity — see
91
- // export.ts:339) — there is no real Auth to pass through. This sentinel satisfies
92
- // resolveDbConnectionClient's signature without adding a gate that never existed;
93
- // canUseBuiltin gating is for connection *management* by a user, not for serving an
94
- // already-published export.
91
+ // Export execution has no user behind it (a public export is read without a session -- see
92
+ // routes/export) -- there is no real Auth to pass through. This sentinel satisfies
93
+ // resolveDbConnectionClient's signature; canUseBuiltin gating is for connection *management*
94
+ // by a user, not for serving an already-published export.
95
95
  const SYSTEM_AUTH = { userId: 'system', username: 'system', role: 'admin' };
96
- async function executeExport(nodeSchema, project, config) {
96
+ // Nodes that start a job (transcoding, rendering, generating...): work, and cost, on the
97
+ // server or a provider, for whoever asks.
98
+ const JOB_NODES = new Set([
99
+ 'transcodeNode', 'captionNode', 'thumbnailNode', 'ingestNode', 'renderJobNode', 'formatConvertNode',
100
+ 'lodNode', 'transcriptionNode', 'upscaleNode', 'proceduralAssetNode',
101
+ ]);
102
+ const notPublic = (node) => new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}) can't run in a public export.`);
103
+ async function executeExport(nodeSchema, project, config, { public: isPublic = true } = {}) {
97
104
  const nodes = nodeSchema.nodes;
98
105
  const edges = nodeSchema.edges;
99
106
  // 1. Data layer — nodes connected to streamby in-bottom
@@ -119,6 +126,8 @@ async function executeExport(nodeSchema, project, config) {
119
126
  if (!connectionId) {
120
127
  throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}) has no database connection — connect one on the node.`);
121
128
  }
129
+ if (isPublic && (0, connectionResolver_1.findDbConnection)(project, connectionId)?.source === 'integration')
130
+ throw notPublic(node);
122
131
  const recordId = node.data?.recordId;
123
132
  const projectIdentifier = project._id?.toString() ?? project.id;
124
133
  const resolved = await (0, connectionResolver_1.resolveDbConnectionClient)(project, connectionId, config, SYSTEM_AUTH);
@@ -158,6 +167,8 @@ async function executeExport(nodeSchema, project, config) {
158
167
  // NodeExecutionError (matching dataSourceNode/ingestNode's intentional pattern), since
159
168
  // a silently-skipped service action would produce a workflow that "succeeds" while
160
169
  // quietly missing the one thing the node exists to do.
170
+ if (isPublic)
171
+ throw notPublic(node);
161
172
  const connectionId = node.data?.connectionId;
162
173
  if (!connectionId) {
163
174
  throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}) has no integration connection — connect one on the node.`);
@@ -166,21 +177,26 @@ async function executeExport(nodeSchema, project, config) {
166
177
  if (!connection) {
167
178
  throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}): integration connection not found on this project.`);
168
179
  }
169
- const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(connection.integrationId, config);
170
- if (!resolved) {
171
- throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}): integration credential could not be resolved — it may have been disconnected.`);
172
- }
173
180
  const endpoint = node.data?.endpoint;
174
181
  if (!endpoint) {
175
182
  throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}) has no endpoint configured.`);
176
183
  }
184
+ // Checked before the credential is even resolved: it's only ever sent to its provider.
185
+ if (!(0, serviceEndpoints_1.isServiceEndpoint)(connection.provider, endpoint)) {
186
+ throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}): ${endpoint} is not a ${connection.provider} endpoint.`);
187
+ }
188
+ const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(connection.integrationId, config, project);
189
+ if (!resolved) {
190
+ throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}): integration credential could not be resolved — it may have been disconnected.`);
191
+ }
177
192
  const method = node.data?.method ?? 'GET';
178
193
  const headers = {
179
194
  'Content-Type': 'application/json',
180
195
  ...(0, oauthClient_1.buildServiceAuthHeaders)(connection.provider, resolved.credential),
181
196
  };
182
197
  const body = node.data?.body ? JSON.stringify(node.data.body) : undefined;
183
- const response = await fetch(endpoint, { method, headers, body });
198
+ // A redirect would carry the credential's headers on to wherever it points.
199
+ const response = await fetch(endpoint, { method, headers, body, redirect: 'error' });
184
200
  if (!response.ok) {
185
201
  throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}): integration action failed (${response.statusText}).`);
186
202
  }
@@ -197,6 +213,8 @@ async function executeExport(nodeSchema, project, config) {
197
213
  while (processNode) {
198
214
  const nodeData = processNode.data ?? {};
199
215
  const fileId = nodeData.fileId;
216
+ if (isPublic && JOB_NODES.has(processNode.type))
217
+ throw notPublic(processNode);
200
218
  if (processNode.type === 'transcodeNode' && fileId) {
201
219
  const job = (0, jobQueue_1.createJob)('transcode', systemUserId, projectId, nodeData);
202
220
  setImmediate(() => (0, mediaProcessor_1.runTranscodeJob)(job.jobId, fileId, projectId, {
@@ -0,0 +1,5 @@
1
+ export interface ReencryptResult {
2
+ updated: number;
3
+ failed: number;
4
+ }
5
+ export declare function reencryptStoredCredentials(): Promise<ReencryptResult>;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.reencryptStoredCredentials = reencryptStoredCredentials;
4
+ const manager_1 = require("../models/manager");
5
+ const encryption_1 = require("../utils/encryption");
6
+ // Brings every stored credential to the current encryption (AES-256-GCM with the current key,
7
+ // see utils/encryption): those encrypted before (CBC, or GCM with a previous key) are
8
+ // re-encrypted, and the distribution and render farm credentials, stored as they were sent,
9
+ // are encrypted. Non-destructive: a value is replaced only by its own re-encryption; one that
10
+ // can't be decrypted is left as it is (and counted). Runs at startup; once everything is
11
+ // current, it changes nothing.
12
+ // A project's lists of connections with a credential each, encrypted before.
13
+ const ENCRYPTED_LISTS = ['connections', 'dbConnections', 'storageConnections', 'exports'];
14
+ // The ones whose credentials were stored as sent, never encrypted.
15
+ const PLAIN_LISTS = ['distributionConnections', 'renderFarmConnections'];
16
+ const INTEGRATION_FIELDS = ['encryptedCredential', 'encryptedAccessToken', 'encryptedRefreshToken'];
17
+ async function reencryptStoredCredentials() {
18
+ const result = { updated: 0, failed: 0 };
19
+ if (!(0, encryption_1.isEncryptionKeySet)())
20
+ return result;
21
+ // The current encryption of a stored value, or undefined when it's current already or
22
+ // can't be read (left as it is).
23
+ const upgrade = (value, storedPlain) => {
24
+ if (typeof value !== 'string' || !value || (0, encryption_1.isCurrentEncryption)(value))
25
+ return undefined;
26
+ if (storedPlain && !(0, encryption_1.isVersionedEncryption)(value))
27
+ return (0, encryption_1.encrypt)(value);
28
+ try {
29
+ return (0, encryption_1.encrypt)((0, encryption_1.decrypt)(value));
30
+ }
31
+ catch {
32
+ result.failed++;
33
+ return undefined;
34
+ }
35
+ };
36
+ const Project = (0, manager_1.getModel)('projects');
37
+ for (const project of (await Project.find({}))) {
38
+ const changes = {};
39
+ for (const list of [...ENCRYPTED_LISTS, ...PLAIN_LISTS]) {
40
+ const entries = project[list];
41
+ if (!Array.isArray(entries))
42
+ continue;
43
+ const storedPlain = PLAIN_LISTS.includes(list);
44
+ let changed = false;
45
+ const next = entries.map((entry) => {
46
+ const upgraded = upgrade(entry?.encryptedCredential, storedPlain);
47
+ if (upgraded === undefined)
48
+ return entry;
49
+ changed = true;
50
+ return { ...entry, encryptedCredential: upgraded };
51
+ });
52
+ if (changed)
53
+ changes[list] = next;
54
+ }
55
+ if (Object.keys(changes).length) {
56
+ await Project.update({ _id: project._id ?? project.id }, changes);
57
+ result.updated++;
58
+ }
59
+ }
60
+ const UserIntegration = (0, manager_1.getModel)('user_integrations');
61
+ for (const integration of (await UserIntegration.find({}))) {
62
+ const changes = {};
63
+ for (const field of INTEGRATION_FIELDS) {
64
+ const upgraded = upgrade(integration[field], false);
65
+ if (upgraded !== undefined)
66
+ changes[field] = upgraded;
67
+ }
68
+ if (Object.keys(changes).length) {
69
+ await UserIntegration.update({ id: integration.id }, changes);
70
+ result.updated++;
71
+ }
72
+ }
73
+ return result;
74
+ }
@@ -1,4 +1,4 @@
1
- import { IntegrationKind, ServiceProviderType, StreamByConfig, UserIntegration } from '../types';
1
+ import { IntegrationKind, ProjectInfo, ServiceProviderType, StreamByConfig, UserIntegration } from '../types';
2
2
  export declare function isOAuthServiceProvider(provider: string): boolean;
3
3
  export declare class OAuthRefreshError extends Error {
4
4
  provider: string;
@@ -34,4 +34,4 @@ export interface ResolvedCredential {
34
34
  credential: unknown;
35
35
  }
36
36
  export declare function getDecryptedIntegrationCredential(userId: string, integrationId: string, config: StreamByConfig): Promise<ResolvedCredential | null>;
37
- export declare function getDecryptedIntegrationCredentialById(integrationId: string, config: StreamByConfig): Promise<ResolvedCredential | null>;
37
+ export declare function getDecryptedIntegrationCredentialById(integrationId: string, config: StreamByConfig, project: Pick<ProjectInfo, 'members'>): Promise<ResolvedCredential | null>;
@@ -15,6 +15,7 @@ exports.getDecryptedIntegrationCredentialById = getDecryptedIntegrationCredentia
15
15
  const crypto_1 = __importDefault(require("crypto"));
16
16
  const manager_1 = require("../models/manager");
17
17
  const encryption_1 = require("../utils/encryption");
18
+ const auth_1 = require("../utils/auth");
18
19
  const oauthClient_1 = require("./oauthClient");
19
20
  const OAUTH_SERVICE_PROVIDERS = new Set(['jira', 'google']);
20
21
  function isOAuthServiceProvider(provider) {
@@ -157,9 +158,14 @@ async function getDecryptedIntegrationCredential(userId, integrationId, config)
157
158
  // stored on the connection itself. `config` is required (not just for OAuth's sake) so a
158
159
  // near-expiry jira/google token can be refreshed using the host-injected client
159
160
  // credentials in config.oauthProviders.
160
- async function getDecryptedIntegrationCredentialById(integrationId, config) {
161
+ // A member's own integration, used by a project it was connected to: it resolves only while its
162
+ // owner is an active member of that project. A project its owner isn't in (never was, left it,
163
+ // or a connection pointing at someone else's integration) never gets the credential.
164
+ async function getDecryptedIntegrationCredentialById(integrationId, config, project) {
161
165
  const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
162
166
  const integration = await UserIntegrationModel.findOne({ id: integrationId });
167
+ if (!integration || !(0, auth_1.activeMemberOf)(project, integration.userId))
168
+ return null;
163
169
  return resolveCredential(integration, config);
164
170
  }
165
171
  async function decryptIntegration(integration) {
@@ -15,6 +15,12 @@ function initWsHub(wss, config) {
15
15
  ws.close(1008, 'Unauthorized');
16
16
  return;
17
17
  }
18
+ // The WebSocket is the browser session's: it gets every event of its user, whatever the
19
+ // project, so an API token (scoped to some projects, maybe read-only) never opens it.
20
+ if (auth.apiToken) {
21
+ ws.close(1008, "API tokens can't open the WebSocket");
22
+ return;
23
+ }
18
24
  userId = auth.userId;
19
25
  }
20
26
  catch (err) {
@@ -63,6 +63,7 @@ export interface ProjectInfo {
63
63
  role: 'viewer' | 'editor' | 'admin';
64
64
  userId: string;
65
65
  archived?: boolean;
66
+ status?: 'active' | 'pending';
66
67
  }[];
67
68
  description?: string;
68
69
  exports?: {
@@ -131,6 +132,7 @@ export interface StreamByConfig {
131
132
  databases?: DatabaseCredential[];
132
133
  adapter?: StorageAdapter;
133
134
  encrypt?: string;
135
+ encryptPreviousKeys?: string[];
134
136
  websocket?: {
135
137
  server: WebSocketServer;
136
138
  };
@@ -156,6 +158,13 @@ export interface Auth {
156
158
  userId: string;
157
159
  username: string;
158
160
  role: 'viewer' | 'editor' | 'admin';
161
+ apiToken?: ApiTokenScope;
162
+ }
163
+ export type ApiTokenAccess = 'read' | 'write';
164
+ export interface ApiTokenScope {
165
+ tokenId: string;
166
+ access: ApiTokenAccess;
167
+ projectIds: string[] | '*';
159
168
  }
160
169
  export type AuthProvider = (req: Request) => Promise<Auth>;
161
170
  export type UserPlan = 'freemium' | 'subscriber' | 'admin';
@@ -1,2 +1,16 @@
1
1
  import { ProjectInfo } from '../types';
2
- export declare function isProjectMember(project: ProjectInfo, userId: string): boolean;
2
+ type ProjectMembers = Pick<ProjectInfo, 'members'> | null | undefined;
3
+ export declare function activeMemberOf(project: ProjectMembers, userId: string | undefined): {
4
+ role: "viewer" | "editor" | "admin";
5
+ userId: string;
6
+ archived?: boolean;
7
+ status?: "active" | "pending";
8
+ } | undefined;
9
+ export declare const projectRoleOf: (project: ProjectMembers, userId: string | undefined) => "viewer" | "editor" | "admin" | undefined;
10
+ /** An active member, whatever their role: can see the project. */
11
+ export declare const isProjectMember: (project: ProjectMembers, userId: string | undefined) => boolean;
12
+ /** An editor or an admin of the project: can change it (its content, its connections). */
13
+ export declare const canEditProject: (project: ProjectMembers, userId: string | undefined) => boolean;
14
+ /** An admin of the project: can manage its members, and delete it. */
15
+ export declare const isProjectAdmin: (project: ProjectMembers, userId: string | undefined) => boolean;
16
+ export {};
@@ -1,9 +1,24 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isProjectMember = isProjectMember;
4
- function isProjectMember(project, userId) {
5
- if (!project || !project.members) {
6
- return false;
7
- }
8
- return project.members.some(member => member.userId === userId);
3
+ exports.isProjectAdmin = exports.canEditProject = exports.isProjectMember = exports.projectRoleOf = void 0;
4
+ exports.activeMemberOf = activeMemberOf;
5
+ // A project's member who's in it now: invited and accepted (status 'active'). (A member's
6
+ // `archived` is only that member having archived the project in their own list.)
7
+ function activeMemberOf(project, userId) {
8
+ if (!project?.members || !userId)
9
+ return undefined;
10
+ return project.members.find(member => member.userId?.toString() === userId && member.status === 'active');
9
11
  }
12
+ // What a user can do in a project is their role in it, as an active member -- never their
13
+ // role on the platform (Auth.role): a pending invitation is no access yet.
14
+ const projectRoleOf = (project, userId) => activeMemberOf(project, userId)?.role;
15
+ exports.projectRoleOf = projectRoleOf;
16
+ /** An active member, whatever their role: can see the project. */
17
+ const isProjectMember = (project, userId) => !!(0, exports.projectRoleOf)(project, userId);
18
+ exports.isProjectMember = isProjectMember;
19
+ /** An editor or an admin of the project: can change it (its content, its connections). */
20
+ const canEditProject = (project, userId) => ['editor', 'admin'].includes((0, exports.projectRoleOf)(project, userId) ?? '');
21
+ exports.canEditProject = canEditProject;
22
+ /** An admin of the project: can manage its members, and delete it. */
23
+ const isProjectAdmin = (project, userId) => (0, exports.projectRoleOf)(project, userId) === 'admin';
24
+ exports.isProjectAdmin = isProjectAdmin;
@@ -1,4 +1,9 @@
1
- export declare const setEncryptionKey: (key: string) => void;
1
+ /** The key credentials are encrypted with, and the previous ones they may still be in. */
2
+ export declare const setEncryptionKey: (key: string, previousKeys?: string[]) => void;
2
3
  export declare const encrypt: (text: string) => string;
3
4
  export declare const decrypt: (text: string) => string;
5
+ /** Whether a stored value is encrypted the current way (GCM, with the current key). */
6
+ export declare const isCurrentEncryption: (text: string) => boolean;
7
+ /** Whether a stored value is in this module's own format (GCM, any key). */
8
+ export declare const isVersionedEncryption: (text: string) => boolean;
4
9
  export declare const isEncryptionKeySet: () => boolean;