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.
Files changed (55) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +661 -0
  3. package/README.md +53 -2
  4. package/dist/middleware/createRouter.js +17 -2
  5. package/dist/middleware/routes/ai.js +3 -3
  6. package/dist/middleware/routes/connection.js +3 -12
  7. package/dist/middleware/routes/dbConnection.js +7 -26
  8. package/dist/middleware/routes/distribution.js +12 -5
  9. package/dist/middleware/routes/export.js +5 -4
  10. package/dist/middleware/routes/media.js +23 -10
  11. package/dist/middleware/routes/member.js +3 -12
  12. package/dist/middleware/routes/{credential.d.ts → oauth.d.ts} +1 -1
  13. package/dist/middleware/routes/oauth.js +101 -0
  14. package/dist/middleware/routes/pipeline.js +3 -3
  15. package/dist/middleware/routes/production.js +9 -9
  16. package/dist/middleware/routes/project.js +24 -31
  17. package/dist/middleware/routes/renderFarm.js +13 -6
  18. package/dist/middleware/routes/review.js +4 -4
  19. package/dist/middleware/routes/rights.js +1 -1
  20. package/dist/middleware/routes/storage.js +21 -15
  21. package/dist/middleware/routes/storageConnection.js +2 -8
  22. package/dist/middleware/routes/userIntegration.js +17 -2
  23. package/dist/middleware/routes/workflow.js +1 -1
  24. package/dist/services/auth.d.ts +1 -2
  25. package/dist/services/auth.js +9 -11
  26. package/dist/services/connectionResolver.js +6 -6
  27. package/dist/services/executor.d.ts +4 -1
  28. package/dist/services/executor.js +63 -6
  29. package/dist/services/oauthClient.d.ts +10 -0
  30. package/dist/services/oauthClient.js +134 -0
  31. package/dist/services/projectIntegration.d.ts +4 -1
  32. package/dist/services/projectIntegration.js +13 -3
  33. package/dist/services/reencryptCredentials.d.ts +5 -0
  34. package/dist/services/reencryptCredentials.js +74 -0
  35. package/dist/services/userIntegration.d.ts +23 -3
  36. package/dist/services/userIntegration.js +130 -7
  37. package/dist/types/index.d.ts +31 -3
  38. package/dist/utils/auth.d.ts +15 -1
  39. package/dist/utils/auth.js +21 -6
  40. package/dist/utils/encryption.d.ts +6 -1
  41. package/dist/utils/encryption.js +80 -27
  42. package/dist/utils/sanitize.d.ts +6 -0
  43. package/dist/utils/sanitize.js +18 -1
  44. package/dist/utils/serviceEndpoints.d.ts +2 -0
  45. package/dist/utils/serviceEndpoints.js +26 -0
  46. package/package.json +4 -3
  47. package/dist/middleware/routes/apiConnection.d.ts +0 -3
  48. package/dist/middleware/routes/apiConnection.js +0 -100
  49. package/dist/middleware/routes/credential.js +0 -80
  50. package/dist/services/apiConnection.d.ts +0 -18
  51. package/dist/services/apiConnection.js +0 -65
  52. package/dist/services/credential.d.ts +0 -8
  53. package/dist/services/credential.js +0 -70
  54. package/dist/services/pipeline.d.ts +0 -2
  55. 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)
@@ -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.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();
@@ -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;
@@ -1,9 +1,15 @@
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.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.includes('/export/')) {
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 credential = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId);
56
- if (!credential)
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 s3Config = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId);
113
- if (!s3Config)
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(s3Config);
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 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>;
@@ -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 is public (gated by allowedOrigin, not user identity — see
89
- // export.ts:339) — there is no real Auth to pass through. This sentinel satisfies
90
- // resolveDbConnectionClient's signature without adding a gate that never existed;
91
- // canUseBuiltin gating is for connection *management* by a user, not for serving an
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
- 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 } = {}) {
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: 'storage',
67
+ kind: 'service',
58
68
  connection: {
59
- id: crypto_1.default.randomUUID(), name: integration.name, type: integration.provider,
60
- projectId, createdAt: now, isBuiltin: false, integrationId: id, source: 'integration',
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,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,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 declare function getDecryptedIntegrationCredential(userId: string, integrationId: string): Promise<unknown | null>;
17
- export declare function getDecryptedIntegrationCredentialById(integrationId: string): Promise<unknown | null>;
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>;