streamby-core 0.32.1 → 0.34.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 +67 -0
- package/LICENSE +661 -0
- package/README.md +53 -2
- package/dist/middleware/createRouter.js +17 -2
- package/dist/middleware/routes/ai.js +3 -3
- 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 +23 -10
- package/dist/middleware/routes/member.js +3 -12
- package/dist/middleware/routes/{credential.d.ts → oauth.d.ts} +1 -1
- package/dist/middleware/routes/oauth.js +101 -0
- package/dist/middleware/routes/pipeline.js +3 -3
- package/dist/middleware/routes/production.js +9 -9
- package/dist/middleware/routes/project.js +24 -31
- 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/userIntegration.js +17 -2
- package/dist/middleware/routes/workflow.js +1 -1
- package/dist/services/auth.d.ts +1 -2
- package/dist/services/auth.js +9 -11
- package/dist/services/connectionResolver.js +6 -6
- package/dist/services/executor.d.ts +4 -1
- package/dist/services/executor.js +63 -6
- package/dist/services/oauthClient.d.ts +10 -0
- package/dist/services/oauthClient.js +134 -0
- package/dist/services/projectIntegration.d.ts +4 -1
- package/dist/services/projectIntegration.js +13 -3
- package/dist/services/reencryptCredentials.d.ts +5 -0
- package/dist/services/reencryptCredentials.js +74 -0
- package/dist/services/userIntegration.d.ts +23 -3
- package/dist/services/userIntegration.js +130 -7
- package/dist/types/index.d.ts +31 -3
- 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/sanitize.d.ts +6 -0
- package/dist/utils/sanitize.js +18 -1
- 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)
|
|
@@ -5,9 +5,10 @@ const express_1 = require("express");
|
|
|
5
5
|
const builtinAccess_1 = require("../../utils/builtinAccess");
|
|
6
6
|
const userIntegration_1 = require("../../services/userIntegration");
|
|
7
7
|
const sanitize_1 = require("../../utils/sanitize");
|
|
8
|
-
const VALID_KINDS = ['database', 'storage'];
|
|
8
|
+
const VALID_KINDS = ['database', 'storage', 'service'];
|
|
9
9
|
const VALID_DB_PROVIDERS = ['postgresql', 'mongodb'];
|
|
10
10
|
const VALID_STORAGE_PROVIDERS = ['s3', 'gcs', 'r2', 'azure'];
|
|
11
|
+
const VALID_SERVICE_PROVIDERS = ['claude', 'jira', 'google'];
|
|
11
12
|
function userIntegrationRouter(config) {
|
|
12
13
|
const router = (0, express_1.Router)();
|
|
13
14
|
// ─── Combined pool: account integrations + built-ins the user can access ─────
|
|
@@ -42,10 +43,18 @@ function userIntegrationRouter(config) {
|
|
|
42
43
|
if (!kind || !VALID_KINDS.includes(kind)) {
|
|
43
44
|
return res.status(400).json({ message: `kind must be one of: ${VALID_KINDS.join(', ')}` });
|
|
44
45
|
}
|
|
45
|
-
const validProviders = kind === 'database' ? VALID_DB_PROVIDERS : VALID_STORAGE_PROVIDERS;
|
|
46
|
+
const validProviders = kind === 'database' ? VALID_DB_PROVIDERS : kind === 'storage' ? VALID_STORAGE_PROVIDERS : VALID_SERVICE_PROVIDERS;
|
|
46
47
|
if (!provider || !validProviders.includes(provider)) {
|
|
47
48
|
return res.status(400).json({ message: `provider must be one of: ${validProviders.join(', ')}` });
|
|
48
49
|
}
|
|
50
|
+
// jira/google have no upfront credential to supply — they can only be linked via the
|
|
51
|
+
// OAuth consent flow, which mints the token itself. Claude (API key) goes through the
|
|
52
|
+
// normal path below unchanged.
|
|
53
|
+
if (kind === 'service' && (0, userIntegration_1.isOAuthServiceProvider)(provider)) {
|
|
54
|
+
return res.status(400).json({
|
|
55
|
+
message: `Provider '${provider}' requires OAuth — start at GET /user/integrations/oauth/${provider}/start instead of POST /user/integrations`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
49
58
|
if (!name)
|
|
50
59
|
return res.status(400).json({ message: 'name is required' });
|
|
51
60
|
if (credential === undefined || credential === null) {
|
|
@@ -57,6 +66,9 @@ function userIntegrationRouter(config) {
|
|
|
57
66
|
res.status(201).json({ data: (0, sanitize_1.sanitizeUserIntegration)(integration) });
|
|
58
67
|
}
|
|
59
68
|
catch (err) {
|
|
69
|
+
if (err instanceof userIntegration_1.InvalidCredentialUpdateError) {
|
|
70
|
+
return res.status(400).json({ message: err.message });
|
|
71
|
+
}
|
|
60
72
|
res.status(500).json({ message: 'Failed to create integration', details: err.message });
|
|
61
73
|
}
|
|
62
74
|
});
|
|
@@ -73,6 +85,9 @@ function userIntegrationRouter(config) {
|
|
|
73
85
|
res.json({ data: (0, sanitize_1.sanitizeUserIntegration)(updated) });
|
|
74
86
|
}
|
|
75
87
|
catch (err) {
|
|
88
|
+
if (err instanceof userIntegration_1.InvalidCredentialUpdateError) {
|
|
89
|
+
return res.status(400).json({ message: err.message });
|
|
90
|
+
}
|
|
76
91
|
res.status(500).json({ message: 'Failed to update integration', details: err.message });
|
|
77
92
|
}
|
|
78
93
|
});
|
|
@@ -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();
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { Auth } from '../types';
|
|
2
1
|
import { Request, Response, NextFunction } from 'express';
|
|
3
2
|
import { StreamByConfig } from '../types';
|
|
3
|
+
export declare const isPublicRoute: (method: string, path: string) => boolean;
|
|
4
4
|
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,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.authenticate = void 0;
|
|
4
|
-
|
|
3
|
+
exports.authenticate = 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;
|
|
5
11
|
const authenticate = (config) => async (req, res, next) => {
|
|
6
|
-
if (req.path
|
|
12
|
+
if ((0, exports.isPublicRoute)(req.method, req.path)) {
|
|
7
13
|
return next();
|
|
8
14
|
}
|
|
9
15
|
try {
|
|
@@ -20,11 +26,3 @@ const authenticate = (config) => async (req, res, next) => {
|
|
|
20
26
|
}
|
|
21
27
|
};
|
|
22
28
|
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,10 +52,10 @@ 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
|
|
56
|
-
if (!
|
|
55
|
+
const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId, config, project);
|
|
56
|
+
if (!resolved)
|
|
57
57
|
return { error: 'Integration not found', status: 400 };
|
|
58
|
-
connectionString = credential;
|
|
58
|
+
connectionString = resolved.credential;
|
|
59
59
|
}
|
|
60
60
|
else {
|
|
61
61
|
if (!(0, encryption_1.isEncryptionKeySet)())
|
|
@@ -109,10 +109,10 @@ 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
|
|
113
|
-
if (!
|
|
112
|
+
const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId, config, project);
|
|
113
|
+
if (!resolved)
|
|
114
114
|
return { error: 'Integration not found', status: 400 };
|
|
115
|
-
return new s3_1.S3Adapter(
|
|
115
|
+
return new s3_1.S3Adapter(resolved.credential);
|
|
116
116
|
}
|
|
117
117
|
catch (e) {
|
|
118
118
|
return { error: `Failed to initialize storage adapter: ${e.message}`, status: 500 };
|
|
@@ -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>;
|
|
@@ -5,11 +5,14 @@ exports.executeExport = executeExport;
|
|
|
5
5
|
const encryption_1 = require("../utils/encryption");
|
|
6
6
|
const dbConnection_1 = require("./dbConnection");
|
|
7
7
|
const connectionResolver_1 = require("./connectionResolver");
|
|
8
|
+
const userIntegration_1 = require("./userIntegration");
|
|
9
|
+
const oauthClient_1 = require("./oauthClient");
|
|
8
10
|
const jobQueue_1 = require("./jobQueue");
|
|
9
11
|
const mediaProcessor_1 = require("./mediaProcessor");
|
|
10
12
|
const vfxProcessor_1 = require("./vfxProcessor");
|
|
11
13
|
const aiProcessor_1 = require("./aiProcessor");
|
|
12
14
|
const storage_1 = require("../providers/storage");
|
|
15
|
+
const serviceEndpoints_1 = require("../utils/serviceEndpoints");
|
|
13
16
|
function applyFilterConfig(payload, config) {
|
|
14
17
|
const isArr = Array.isArray(payload);
|
|
15
18
|
let r = isArr ? [...payload] : payload;
|
|
@@ -85,13 +88,19 @@ class NodeExecutionError extends Error {
|
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
exports.NodeExecutionError = NodeExecutionError;
|
|
88
|
-
// Export execution
|
|
89
|
-
// export
|
|
90
|
-
// resolveDbConnectionClient's signature
|
|
91
|
-
//
|
|
92
|
-
// 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.
|
|
93
95
|
const SYSTEM_AUTH = { userId: 'system', username: 'system', role: 'admin' };
|
|
94
|
-
|
|
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 } = {}) {
|
|
95
104
|
const nodes = nodeSchema.nodes;
|
|
96
105
|
const edges = nodeSchema.edges;
|
|
97
106
|
// 1. Data layer — nodes connected to streamby in-bottom
|
|
@@ -117,6 +126,8 @@ async function executeExport(nodeSchema, project, config) {
|
|
|
117
126
|
if (!connectionId) {
|
|
118
127
|
throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}) has no database connection — connect one on the node.`);
|
|
119
128
|
}
|
|
129
|
+
if (isPublic && (0, connectionResolver_1.findDbConnection)(project, connectionId)?.source === 'integration')
|
|
130
|
+
throw notPublic(node);
|
|
120
131
|
const recordId = node.data?.recordId;
|
|
121
132
|
const projectIdentifier = project._id?.toString() ?? project.id;
|
|
122
133
|
const resolved = await (0, connectionResolver_1.resolveDbConnectionClient)(project, connectionId, config, SYSTEM_AUTH);
|
|
@@ -147,6 +158,50 @@ async function executeExport(nodeSchema, project, config) {
|
|
|
147
158
|
throw new Error(`Connection fetch failed: ${response.statusText}`);
|
|
148
159
|
dataResults.push(await response.json());
|
|
149
160
|
}
|
|
161
|
+
else if (node.type === 'integrationConnectionNode') {
|
|
162
|
+
// Generic action over a project-level IntegrationConnection (Claude/Jira/Google) — the
|
|
163
|
+
// node's data carries the endpoint/method/body to invoke, same shape family as
|
|
164
|
+
// connectionNode's apiUrl/method, but the auth header comes from the resolved
|
|
165
|
+
// account-level integration credential instead of a static per-connection secret.
|
|
166
|
+
// Diverges from connectionNode's silent-skip: a missing connection/credential throws
|
|
167
|
+
// NodeExecutionError (matching dataSourceNode/ingestNode's intentional pattern), since
|
|
168
|
+
// a silently-skipped service action would produce a workflow that "succeeds" while
|
|
169
|
+
// quietly missing the one thing the node exists to do.
|
|
170
|
+
if (isPublic)
|
|
171
|
+
throw notPublic(node);
|
|
172
|
+
const connectionId = node.data?.connectionId;
|
|
173
|
+
if (!connectionId) {
|
|
174
|
+
throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}) has no integration connection — connect one on the node.`);
|
|
175
|
+
}
|
|
176
|
+
const connection = (project.integrationConnections ?? []).find((c) => c.id === connectionId);
|
|
177
|
+
if (!connection) {
|
|
178
|
+
throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}): integration connection not found on this project.`);
|
|
179
|
+
}
|
|
180
|
+
const endpoint = node.data?.endpoint;
|
|
181
|
+
if (!endpoint) {
|
|
182
|
+
throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}) has no endpoint configured.`);
|
|
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
|
+
}
|
|
192
|
+
const method = node.data?.method ?? 'GET';
|
|
193
|
+
const headers = {
|
|
194
|
+
'Content-Type': 'application/json',
|
|
195
|
+
...(0, oauthClient_1.buildServiceAuthHeaders)(connection.provider, resolved.credential),
|
|
196
|
+
};
|
|
197
|
+
const body = node.data?.body ? JSON.stringify(node.data.body) : undefined;
|
|
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' });
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}): integration action failed (${response.statusText}).`);
|
|
202
|
+
}
|
|
203
|
+
dataResults.push(await response.json());
|
|
204
|
+
}
|
|
150
205
|
}
|
|
151
206
|
let payload = dataResults.length === 1 ? dataResults[0] : dataResults;
|
|
152
207
|
// 2. Process layer — chain from streamby out-top → processNode in-process → out-process → ...
|
|
@@ -158,6 +213,8 @@ async function executeExport(nodeSchema, project, config) {
|
|
|
158
213
|
while (processNode) {
|
|
159
214
|
const nodeData = processNode.data ?? {};
|
|
160
215
|
const fileId = nodeData.fileId;
|
|
216
|
+
if (isPublic && JOB_NODES.has(processNode.type))
|
|
217
|
+
throw notPublic(processNode);
|
|
161
218
|
if (processNode.type === 'transcodeNode' && fileId) {
|
|
162
219
|
const job = (0, jobQueue_1.createJob)('transcode', systemUserId, projectId, nodeData);
|
|
163
220
|
setImmediate(() => (0, mediaProcessor_1.runTranscodeJob)(job.jobId, fileId, projectId, {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { OAuthClientConfig, ServiceProviderType } from '../types';
|
|
2
|
+
export interface ExchangedTokens {
|
|
3
|
+
accessToken: string;
|
|
4
|
+
refreshToken: string;
|
|
5
|
+
expiresAt: Date;
|
|
6
|
+
}
|
|
7
|
+
export declare function buildAuthorizeUrl(provider: 'jira' | 'google', config: OAuthClientConfig, state: string): string;
|
|
8
|
+
export declare function exchangeCodeForTokens(provider: 'jira' | 'google', config: OAuthClientConfig, code: string): Promise<ExchangedTokens>;
|
|
9
|
+
export declare function refreshTokens(provider: 'jira' | 'google', config: OAuthClientConfig, refreshToken: string): Promise<ExchangedTokens>;
|
|
10
|
+
export declare function buildServiceAuthHeaders(provider: ServiceProviderType, credential: string): Record<string, string>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildAuthorizeUrl = buildAuthorizeUrl;
|
|
4
|
+
exports.exchangeCodeForTokens = exchangeCodeForTokens;
|
|
5
|
+
exports.refreshTokens = refreshTokens;
|
|
6
|
+
exports.buildServiceAuthHeaders = buildServiceAuthHeaders;
|
|
7
|
+
// TODO: adjust once a real Google OAuth app is registered — see TCORE-73 plan §4.
|
|
8
|
+
const GOOGLE_OAUTH_SCOPES = 'openid email profile';
|
|
9
|
+
// MUST include offline_access to receive a refresh token from Atlassian.
|
|
10
|
+
const JIRA_OAUTH_SCOPES = 'offline_access read:jira-user read:jira-work';
|
|
11
|
+
function buildAuthorizeUrl(provider, config, state) {
|
|
12
|
+
if (provider === 'google') {
|
|
13
|
+
const params = new URLSearchParams({
|
|
14
|
+
client_id: config.clientId,
|
|
15
|
+
redirect_uri: config.redirectUri,
|
|
16
|
+
response_type: 'code',
|
|
17
|
+
scope: GOOGLE_OAUTH_SCOPES,
|
|
18
|
+
access_type: 'offline',
|
|
19
|
+
prompt: 'consent',
|
|
20
|
+
state,
|
|
21
|
+
});
|
|
22
|
+
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
|
23
|
+
}
|
|
24
|
+
const params = new URLSearchParams({
|
|
25
|
+
audience: 'api.atlassian.com',
|
|
26
|
+
client_id: config.clientId,
|
|
27
|
+
scope: JIRA_OAUTH_SCOPES,
|
|
28
|
+
redirect_uri: config.redirectUri,
|
|
29
|
+
state,
|
|
30
|
+
response_type: 'code',
|
|
31
|
+
prompt: 'consent',
|
|
32
|
+
});
|
|
33
|
+
return `https://auth.atlassian.com/authorize?${params.toString()}`;
|
|
34
|
+
}
|
|
35
|
+
async function parseTokenResponse(response) {
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const body = await response.text().catch(() => '');
|
|
38
|
+
throw new Error(`Token endpoint returned ${response.status}: ${body}`);
|
|
39
|
+
}
|
|
40
|
+
return response.json();
|
|
41
|
+
}
|
|
42
|
+
async function exchangeCodeForTokens(provider, config, code) {
|
|
43
|
+
if (provider === 'google') {
|
|
44
|
+
const body = new URLSearchParams({
|
|
45
|
+
code,
|
|
46
|
+
client_id: config.clientId,
|
|
47
|
+
client_secret: config.clientSecret,
|
|
48
|
+
redirect_uri: config.redirectUri,
|
|
49
|
+
grant_type: 'authorization_code',
|
|
50
|
+
});
|
|
51
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
54
|
+
body: body.toString(),
|
|
55
|
+
});
|
|
56
|
+
const json = await parseTokenResponse(response);
|
|
57
|
+
if (!json.refresh_token)
|
|
58
|
+
throw new Error('Google did not return a refresh_token — retry with access_type=offline&prompt=consent');
|
|
59
|
+
return {
|
|
60
|
+
accessToken: json.access_token,
|
|
61
|
+
refreshToken: json.refresh_token,
|
|
62
|
+
expiresAt: new Date(Date.now() + json.expires_in * 1000),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const response = await fetch('https://auth.atlassian.com/oauth/token', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'Content-Type': 'application/json' },
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
grant_type: 'authorization_code',
|
|
70
|
+
client_id: config.clientId,
|
|
71
|
+
client_secret: config.clientSecret,
|
|
72
|
+
code,
|
|
73
|
+
redirect_uri: config.redirectUri,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
const json = await parseTokenResponse(response);
|
|
77
|
+
if (!json.refresh_token)
|
|
78
|
+
throw new Error('Atlassian did not return a refresh_token — ensure the offline_access scope is requested');
|
|
79
|
+
return {
|
|
80
|
+
accessToken: json.access_token,
|
|
81
|
+
refreshToken: json.refresh_token,
|
|
82
|
+
expiresAt: new Date(Date.now() + json.expires_in * 1000),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Google's refresh response usually omits refresh_token (the original stays valid);
|
|
86
|
+
// Atlassian ROTATES the refresh token on every use (a new one is returned every time).
|
|
87
|
+
// Callers must preserve the existing refreshToken when this returns undefined for it.
|
|
88
|
+
async function refreshTokens(provider, config, refreshToken) {
|
|
89
|
+
if (provider === 'google') {
|
|
90
|
+
const body = new URLSearchParams({
|
|
91
|
+
refresh_token: refreshToken,
|
|
92
|
+
client_id: config.clientId,
|
|
93
|
+
client_secret: config.clientSecret,
|
|
94
|
+
grant_type: 'refresh_token',
|
|
95
|
+
});
|
|
96
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
99
|
+
body: body.toString(),
|
|
100
|
+
});
|
|
101
|
+
const json = await parseTokenResponse(response);
|
|
102
|
+
return {
|
|
103
|
+
accessToken: json.access_token,
|
|
104
|
+
refreshToken: json.refresh_token ?? refreshToken,
|
|
105
|
+
expiresAt: new Date(Date.now() + json.expires_in * 1000),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const response = await fetch('https://auth.atlassian.com/oauth/token', {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
grant_type: 'refresh_token',
|
|
113
|
+
client_id: config.clientId,
|
|
114
|
+
client_secret: config.clientSecret,
|
|
115
|
+
refresh_token: refreshToken,
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
const json = await parseTokenResponse(response);
|
|
119
|
+
if (!json.refresh_token)
|
|
120
|
+
throw new Error('Atlassian refresh response did not include a rotated refresh_token');
|
|
121
|
+
return {
|
|
122
|
+
accessToken: json.access_token,
|
|
123
|
+
refreshToken: json.refresh_token,
|
|
124
|
+
expiresAt: new Date(Date.now() + json.expires_in * 1000),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Claude uses a dedicated x-api-key header (plus anthropic-version), not Authorization:
|
|
128
|
+
// Bearer — Jira/Google's OAuth access tokens use the standard Bearer scheme.
|
|
129
|
+
function buildServiceAuthHeaders(provider, credential) {
|
|
130
|
+
if (provider === 'claude') {
|
|
131
|
+
return { 'x-api-key': credential, 'anthropic-version': '2023-06-01' };
|
|
132
|
+
}
|
|
133
|
+
return { Authorization: `Bearer ${credential}` };
|
|
134
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { Auth, DbConnection, StorageConnection, StreamByConfig } from '../types';
|
|
1
|
+
import { Auth, DbConnection, StorageConnection, IntegrationConnection, StreamByConfig } from '../types';
|
|
2
2
|
type ClassifyResult = {
|
|
3
3
|
kind: 'database';
|
|
4
4
|
connection: DbConnection;
|
|
5
5
|
} | {
|
|
6
6
|
kind: 'storage';
|
|
7
7
|
connection: StorageConnection;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'service';
|
|
10
|
+
connection: IntegrationConnection;
|
|
8
11
|
} | {
|
|
9
12
|
error: string;
|
|
10
13
|
status: number;
|
|
@@ -53,11 +53,21 @@ async function classifyIntegrationId(id, auth, config, projectId) {
|
|
|
53
53
|
},
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
+
if (integration.kind === 'storage') {
|
|
57
|
+
return {
|
|
58
|
+
kind: 'storage',
|
|
59
|
+
connection: {
|
|
60
|
+
id: crypto_1.default.randomUUID(), name: integration.name, type: integration.provider,
|
|
61
|
+
projectId, createdAt: now, isBuiltin: false, integrationId: id, source: 'integration',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// kind === 'service' — the only remaining IntegrationKind value.
|
|
56
66
|
return {
|
|
57
|
-
kind: '
|
|
67
|
+
kind: 'service',
|
|
58
68
|
connection: {
|
|
59
|
-
id: crypto_1.default.randomUUID(), name: integration.name,
|
|
60
|
-
projectId, createdAt: now,
|
|
69
|
+
id: crypto_1.default.randomUUID(), name: integration.name, provider: integration.provider,
|
|
70
|
+
projectId, createdAt: now, integrationId: id, source: 'integration',
|
|
61
71
|
},
|
|
62
72
|
};
|
|
63
73
|
}
|
|
@@ -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,18 @@
|
|
|
1
|
-
import { IntegrationKind, UserIntegration } from '../types';
|
|
1
|
+
import { IntegrationKind, ProjectInfo, ServiceProviderType, StreamByConfig, UserIntegration } from '../types';
|
|
2
|
+
export declare function isOAuthServiceProvider(provider: string): boolean;
|
|
3
|
+
export declare class OAuthRefreshError extends Error {
|
|
4
|
+
provider: string;
|
|
5
|
+
integrationId: string;
|
|
6
|
+
constructor(provider: string, integrationId: string, message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare class InvalidCredentialUpdateError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
export interface OAuthTokenSet {
|
|
12
|
+
accessToken: string;
|
|
13
|
+
refreshToken: string;
|
|
14
|
+
expiresAt: Date;
|
|
15
|
+
}
|
|
2
16
|
export declare function listUserIntegrations(userId: string): Promise<UserIntegration[]>;
|
|
3
17
|
export declare function createUserIntegration(userId: string, input: {
|
|
4
18
|
kind: IntegrationKind;
|
|
@@ -7,11 +21,17 @@ export declare function createUserIntegration(userId: string, input: {
|
|
|
7
21
|
description?: string;
|
|
8
22
|
credentialValue: unknown;
|
|
9
23
|
}): Promise<UserIntegration>;
|
|
24
|
+
export declare function upsertOAuthServiceIntegration(userId: string, provider: Extract<ServiceProviderType, 'jira' | 'google'>, name: string, tokens: OAuthTokenSet): Promise<UserIntegration>;
|
|
10
25
|
export declare function updateUserIntegration(userId: string, integrationId: string, updates: {
|
|
11
26
|
name?: string;
|
|
12
27
|
description?: string;
|
|
13
28
|
credentialValue?: unknown;
|
|
14
29
|
}): Promise<UserIntegration | null>;
|
|
15
30
|
export declare function deleteUserIntegration(userId: string, integrationId: string): Promise<boolean>;
|
|
16
|
-
export
|
|
17
|
-
|
|
31
|
+
export interface ResolvedCredential {
|
|
32
|
+
provider: string;
|
|
33
|
+
kind: IntegrationKind;
|
|
34
|
+
credential: unknown;
|
|
35
|
+
}
|
|
36
|
+
export declare function getDecryptedIntegrationCredential(userId: string, integrationId: string, config: StreamByConfig): Promise<ResolvedCredential | null>;
|
|
37
|
+
export declare function getDecryptedIntegrationCredentialById(integrationId: string, config: StreamByConfig, project: Pick<ProjectInfo, 'members'>): Promise<ResolvedCredential | null>;
|