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.
- package/CHANGELOG.md +100 -0
- package/LICENSE +661 -0
- package/README.md +111 -11
- package/dist/adapters/database/connectionManager.js +18 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +4 -1
- package/dist/middleware/createRouter.js +19 -2
- package/dist/middleware/routes/ai.js +3 -3
- package/dist/middleware/routes/{credential.d.ts → apiToken.d.ts} +1 -1
- package/dist/middleware/routes/apiToken.js +58 -0
- package/dist/middleware/routes/connection.js +3 -12
- package/dist/middleware/routes/dbConnection.js +7 -26
- package/dist/middleware/routes/distribution.js +12 -5
- package/dist/middleware/routes/export.js +5 -4
- package/dist/middleware/routes/media.js +27 -10
- package/dist/middleware/routes/member.js +3 -12
- package/dist/middleware/routes/pipeline.js +3 -3
- package/dist/middleware/routes/production.js +9 -9
- package/dist/middleware/routes/project.js +12 -25
- package/dist/middleware/routes/renderFarm.js +13 -6
- package/dist/middleware/routes/review.js +4 -4
- package/dist/middleware/routes/rights.js +1 -1
- package/dist/middleware/routes/storage.js +21 -15
- package/dist/middleware/routes/storageConnection.js +2 -8
- package/dist/middleware/routes/workflow.js +1 -1
- package/dist/services/apiTokens.d.ts +47 -0
- package/dist/services/apiTokens.js +110 -0
- package/dist/services/auth.d.ts +5 -3
- package/dist/services/auth.js +49 -11
- package/dist/services/connectionResolver.js +2 -2
- package/dist/services/executor.d.ts +4 -1
- package/dist/services/executor.js +29 -11
- package/dist/services/reencryptCredentials.d.ts +5 -0
- package/dist/services/reencryptCredentials.js +74 -0
- package/dist/services/userIntegration.d.ts +2 -2
- package/dist/services/userIntegration.js +7 -1
- package/dist/services/wsHub.js +6 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/utils/auth.d.ts +15 -1
- package/dist/utils/auth.js +21 -6
- package/dist/utils/encryption.d.ts +6 -1
- package/dist/utils/encryption.js +80 -27
- package/dist/utils/serviceEndpoints.d.ts +2 -0
- package/dist/utils/serviceEndpoints.js +26 -0
- package/package.json +4 -3
- package/dist/middleware/routes/apiConnection.d.ts +0 -3
- package/dist/middleware/routes/apiConnection.js +0 -100
- package/dist/middleware/routes/credential.js +0 -80
- package/dist/services/apiConnection.d.ts +0 -18
- package/dist/services/apiConnection.js +0 -65
- package/dist/services/credential.d.ts +0 -8
- package/dist/services/credential.js +0 -70
- package/dist/services/pipeline.d.ts +0 -2
- 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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -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;
|
package/dist/services/auth.js
CHANGED
|
@@ -1,9 +1,55 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.authenticate = void 0;
|
|
4
|
-
|
|
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
|
|
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
|
|
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
|
|
91
|
-
// export
|
|
92
|
-
// resolveDbConnectionClient's signature
|
|
93
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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) {
|
package/dist/services/wsHub.js
CHANGED
|
@@ -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) {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/utils/auth.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
import { ProjectInfo } from '../types';
|
|
2
|
-
|
|
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 {};
|
package/dist/utils/auth.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isProjectMember =
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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;
|