streamby-core 0.32.0 → 0.33.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.
@@ -23,6 +23,7 @@ const connection_1 = require("./routes/connection");
23
23
  const dbConnection_1 = require("./routes/dbConnection");
24
24
  const storageConnection_1 = require("./routes/storageConnection");
25
25
  const userIntegration_1 = require("./routes/userIntegration");
26
+ const oauth_1 = require("./routes/oauth");
26
27
  const notification_1 = require("./routes/notification");
27
28
  const subscription_1 = require("./routes/subscription");
28
29
  const media_1 = require("./routes/media");
@@ -101,6 +102,7 @@ function createStreamByRouter(config) {
101
102
  router.use((0, dbConnection_1.dbConnectionRouter)(config));
102
103
  router.use((0, storageConnection_1.storageConnectionRouter)(config));
103
104
  router.use((0, userIntegration_1.userIntegrationRouter)(config));
105
+ router.use((0, oauth_1.oauthRouter)(config));
104
106
  router.use((0, notification_1.notificationRouter)(config));
105
107
  router.use((0, subscription_1.subscriptionRouter)(config));
106
108
  router.use((0, media_1.mediaRouter)(config));
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import { StreamByConfig } from '../../types';
3
+ export declare function oauthRouter(config: StreamByConfig): Router;
@@ -0,0 +1,101 @@
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.oauthRouter = oauthRouter;
7
+ const express_1 = require("express");
8
+ const crypto_1 = __importDefault(require("crypto"));
9
+ const oauthClient_1 = require("../../services/oauthClient");
10
+ const userIntegration_1 = require("../../services/userIntegration");
11
+ const OAUTH_PROVIDERS = new Set(['jira', 'google']);
12
+ const PROVIDER_DISPLAY_NAME = {
13
+ jira: 'Jira',
14
+ google: 'Google',
15
+ };
16
+ // In-memory, single-process state store — the OAuth dance completes within a few minutes
17
+ // of the same request cycle, so no DB collection is needed. A restart mid-flow just means
18
+ // the user retries /start, which is harmless.
19
+ const pendingOAuthStates = new Map();
20
+ const STATE_TTL_MS = 10 * 60 * 1000;
21
+ function sweepExpiredStates() {
22
+ const cutoff = Date.now() - STATE_TTL_MS;
23
+ for (const [state, entry] of pendingOAuthStates) {
24
+ if (entry.issuedAt < cutoff)
25
+ pendingOAuthStates.delete(state);
26
+ }
27
+ }
28
+ function isOAuthProvider(value) {
29
+ return OAUTH_PROVIDERS.has(value);
30
+ }
31
+ function oauthRouter(config) {
32
+ const router = (0, express_1.Router)();
33
+ router.get('/user/integrations/oauth/:provider/start', async (req, res) => {
34
+ try {
35
+ const auth = req.auth;
36
+ const { provider } = req.params;
37
+ if (!isOAuthProvider(provider)) {
38
+ return res.status(400).json({ message: `Provider '${provider}' does not support OAuth.` });
39
+ }
40
+ const oauthConfig = config.oauthProviders?.[provider];
41
+ if (!oauthConfig) {
42
+ return res.status(503).json({ message: `OAuth for '${provider}' is not configured on this deployment.` });
43
+ }
44
+ sweepExpiredStates();
45
+ const state = crypto_1.default.randomBytes(32).toString('hex');
46
+ pendingOAuthStates.set(state, { userId: auth.userId, provider, issuedAt: Date.now() });
47
+ res.redirect((0, oauthClient_1.buildAuthorizeUrl)(provider, oauthConfig, state));
48
+ }
49
+ catch (err) {
50
+ res.status(500).json({ message: 'Failed to start OAuth flow', details: err.message });
51
+ }
52
+ });
53
+ router.get('/user/integrations/oauth/:provider/callback', async (req, res) => {
54
+ const { provider: providerParam } = req.params;
55
+ const provider = isOAuthProvider(providerParam) ? providerParam : null;
56
+ const oauthConfig = provider ? config.oauthProviders?.[provider] : undefined;
57
+ const failureRedirect = (reason) => {
58
+ const failureUrl = oauthConfig?.failureRedirectUrl;
59
+ if (!failureUrl)
60
+ return res.status(400).json({ message: `OAuth failed: ${reason}` });
61
+ const url = new URL(failureUrl);
62
+ url.searchParams.set('oauthError', reason);
63
+ if (provider)
64
+ url.searchParams.set('provider', provider);
65
+ return res.redirect(url.toString());
66
+ };
67
+ try {
68
+ const auth = req.auth;
69
+ if (!provider)
70
+ return res.status(400).json({ message: `Provider '${providerParam}' does not support OAuth.` });
71
+ if (!oauthConfig)
72
+ return res.status(503).json({ message: `OAuth for '${provider}' is not configured on this deployment.` });
73
+ if (req.query.error)
74
+ return failureRedirect(String(req.query.error));
75
+ const code = req.query.code;
76
+ const state = req.query.state;
77
+ if (!code || !state)
78
+ return failureRedirect('missing_code');
79
+ const pending = pendingOAuthStates.get(state);
80
+ pendingOAuthStates.delete(state); // single-use, regardless of outcome below
81
+ if (!pending || pending.provider !== provider || pending.userId !== auth.userId) {
82
+ return failureRedirect(pending ? 'state_mismatch' : 'invalid_state');
83
+ }
84
+ let tokens;
85
+ try {
86
+ tokens = await (0, oauthClient_1.exchangeCodeForTokens)(provider, oauthConfig, code);
87
+ }
88
+ catch {
89
+ return failureRedirect('token_exchange_failed');
90
+ }
91
+ await (0, userIntegration_1.upsertOAuthServiceIntegration)(auth.userId, provider, PROVIDER_DISPLAY_NAME[provider], tokens);
92
+ const url = new URL(oauthConfig.successRedirectUrl);
93
+ url.searchParams.set('connected', provider);
94
+ res.redirect(url.toString());
95
+ }
96
+ catch (err) {
97
+ failureRedirect('unexpected_error');
98
+ }
99
+ });
100
+ return router;
101
+ }
@@ -93,6 +93,7 @@ function projectRouter(config) {
93
93
  // builtin id. Any invalid id rejects the whole request (no project is created).
94
94
  const dbConnections = [];
95
95
  const storageConnections = [];
96
+ const integrationConnections = [];
96
97
  if (Array.isArray(integrationIds)) {
97
98
  for (const id of integrationIds) {
98
99
  const result = await (0, projectIntegration_1.classifyIntegrationId)(id, auth, config, '');
@@ -100,8 +101,10 @@ function projectRouter(config) {
100
101
  return res.status(result.status).json({ message: result.error });
101
102
  if (result.kind === 'database')
102
103
  dbConnections.push(result.connection);
103
- else
104
+ else if (result.kind === 'storage')
104
105
  storageConnections.push(result.connection);
106
+ else
107
+ integrationConnections.push(result.connection);
105
108
  }
106
109
  }
107
110
  let newProject = await Project.create({
@@ -115,12 +118,14 @@ function projectRouter(config) {
115
118
  members: [{ userId: auth.userId, username: user.username, role: "admin", status: "active", archived: false }],
116
119
  dbConnections: [],
117
120
  storageConnections: [],
121
+ integrationConnections: [],
118
122
  });
119
123
  const newProjectId = newProject._id || newProject.id;
120
- if (dbConnections.length || storageConnections.length) {
124
+ if (dbConnections.length || storageConnections.length || integrationConnections.length) {
121
125
  const stamped = await Project.update({ _id: newProjectId }, {
122
126
  dbConnections: dbConnections.map(c => ({ ...c, projectId: newProjectId })),
123
127
  storageConnections: storageConnections.map(c => ({ ...c, projectId: newProjectId })),
128
+ integrationConnections: integrationConnections.map(c => ({ ...c, projectId: newProjectId })),
124
129
  });
125
130
  if (stamped)
126
131
  newProject = stamped;
@@ -409,7 +414,7 @@ function projectRouter(config) {
409
414
  return res.status(404).json({ message: 'Project not found' });
410
415
  if (!(0, auth_1.isProjectMember)(project, auth.userId))
411
416
  return res.status(403).json({ message: 'Unauthorized project access' });
412
- const alreadyConnected = [...(project.dbConnections ?? []), ...(project.storageConnections ?? [])]
417
+ const alreadyConnected = [...(project.dbConnections ?? []), ...(project.storageConnections ?? []), ...(project.integrationConnections ?? [])]
413
418
  .some((c) => c.integrationId === integrationId);
414
419
  if (alreadyConnected)
415
420
  return res.status(400).json({ message: 'This integration is already connected to the project' });
@@ -418,7 +423,9 @@ function projectRouter(config) {
418
423
  return res.status(result.status).json({ message: result.error });
419
424
  const updated = result.kind === 'database'
420
425
  ? await Project.update({ _id: projectId }, { $push: { dbConnections: result.connection } })
421
- : await Project.update({ _id: projectId }, { $push: { storageConnections: result.connection } });
426
+ : result.kind === 'storage'
427
+ ? await Project.update({ _id: projectId }, { $push: { storageConnections: result.connection } })
428
+ : await Project.update({ _id: projectId }, { $push: { integrationConnections: result.connection } });
422
429
  if (!updated)
423
430
  return res.status(404).json({ message: 'Project not found or not updated' });
424
431
  res.status(201).json({ data: result.connection, project: (0, sanitize_1.sanitizeProject)(updated) });
@@ -443,11 +450,14 @@ function projectRouter(config) {
443
450
  return res.status(403).json({ message: 'Unauthorized project access' });
444
451
  const dbConn = (project.dbConnections ?? []).find((c) => c.integrationId === integrationId);
445
452
  const storageConn = (project.storageConnections ?? []).find((c) => c.integrationId === integrationId);
446
- if (!dbConn && !storageConn)
453
+ const integrationConn = (project.integrationConnections ?? []).find((c) => c.integrationId === integrationId);
454
+ if (!dbConn && !storageConn && !integrationConn)
447
455
  return res.status(404).json({ message: 'Integration not connected to this project' });
448
456
  const updated = dbConn
449
457
  ? await Project.update({ _id: projectId }, { $pull: { dbConnections: { id: dbConn.id } } })
450
- : await Project.update({ _id: projectId }, { $pull: { storageConnections: { id: storageConn.id } } });
458
+ : storageConn
459
+ ? await Project.update({ _id: projectId }, { $pull: { storageConnections: { id: storageConn.id } } })
460
+ : await Project.update({ _id: projectId }, { $pull: { integrationConnections: { id: integrationConn.id } } });
451
461
  if (!updated)
452
462
  return res.status(404).json({ message: 'Project not found or not updated' });
453
463
  res.status(200).json({ message: 'Integration removed', project: (0, sanitize_1.sanitizeProject)(updated) });
@@ -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
  });
@@ -13,11 +13,16 @@ const userIntegration_1 = require("./userIntegration");
13
13
  const encryption_1 = require("../utils/encryption");
14
14
  const storage_1 = require("../providers/storage");
15
15
  const s3_1 = require("../adapters/storage/s3");
16
+ // Matches by row id first; falls back to matching a builtin row by integrationId (the
17
+ // config id, e.g. 'mongo') for nodes/connections saved before TCORE-69 gave builtin rows
18
+ // their own generated id — same legacy-id class as connFilter in storage.ts.
16
19
  function findDbConnection(project, connId) {
17
- return project.dbConnections?.find((c) => c.id === connId);
20
+ return project.dbConnections?.find((c) => c.id === connId)
21
+ ?? project.dbConnections?.find((c) => c.source === 'builtin' && c.integrationId === connId);
18
22
  }
19
23
  function findStorageConnection(project, connId) {
20
- return project.storageConnections?.find((c) => c.id === connId);
24
+ return project.storageConnections?.find((c) => c.id === connId)
25
+ ?? project.storageConnections?.find((c) => c.source === 'builtin' && c.integrationId === connId);
21
26
  }
22
27
  // `connId` must be the `id` of a real row in project.dbConnections — never a bare
23
28
  // config id (config.databases[].id). Builtin rows reuse the shared pooled client keyed by
@@ -47,10 +52,10 @@ async function resolveDbConnectionClient(project, connId, config, auth) {
47
52
  if (conn.source === 'integration') {
48
53
  if (!conn.integrationId)
49
54
  return { error: 'Connection is missing its integrationId', status: 500 };
50
- const credential = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId);
51
- if (!credential)
55
+ const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId, config);
56
+ if (!resolved)
52
57
  return { error: 'Integration not found', status: 400 };
53
- connectionString = credential;
58
+ connectionString = resolved.credential;
54
59
  }
55
60
  else {
56
61
  if (!(0, encryption_1.isEncryptionKeySet)())
@@ -104,10 +109,10 @@ async function resolveStorageAdapter(project, connId, config, auth) {
104
109
  if (!conn.integrationId)
105
110
  return { error: 'Connection is missing its integrationId', status: 500 };
106
111
  try {
107
- const s3Config = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId);
108
- if (!s3Config)
112
+ const resolved = await (0, userIntegration_1.getDecryptedIntegrationCredentialById)(conn.integrationId, config);
113
+ if (!resolved)
109
114
  return { error: 'Integration not found', status: 400 };
110
- return new s3_1.S3Adapter(s3Config);
115
+ return new s3_1.S3Adapter(resolved.credential);
111
116
  }
112
117
  catch (e) {
113
118
  return { error: `Failed to initialize storage adapter: ${e.message}`, status: 500 };
@@ -5,6 +5,8 @@ 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");
@@ -147,6 +149,43 @@ async function executeExport(nodeSchema, project, config) {
147
149
  throw new Error(`Connection fetch failed: ${response.statusText}`);
148
150
  dataResults.push(await response.json());
149
151
  }
152
+ else if (node.type === 'integrationConnectionNode') {
153
+ // Generic action over a project-level IntegrationConnection (Claude/Jira/Google) — the
154
+ // node's data carries the endpoint/method/body to invoke, same shape family as
155
+ // connectionNode's apiUrl/method, but the auth header comes from the resolved
156
+ // account-level integration credential instead of a static per-connection secret.
157
+ // Diverges from connectionNode's silent-skip: a missing connection/credential throws
158
+ // NodeExecutionError (matching dataSourceNode/ingestNode's intentional pattern), since
159
+ // a silently-skipped service action would produce a workflow that "succeeds" while
160
+ // quietly missing the one thing the node exists to do.
161
+ const connectionId = node.data?.connectionId;
162
+ if (!connectionId) {
163
+ throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}) has no integration connection — connect one on the node.`);
164
+ }
165
+ const connection = (project.integrationConnections ?? []).find((c) => c.id === connectionId);
166
+ if (!connection) {
167
+ throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}): integration connection not found on this project.`);
168
+ }
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
+ const endpoint = node.data?.endpoint;
174
+ if (!endpoint) {
175
+ throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}) has no endpoint configured.`);
176
+ }
177
+ const method = node.data?.method ?? 'GET';
178
+ const headers = {
179
+ 'Content-Type': 'application/json',
180
+ ...(0, oauthClient_1.buildServiceAuthHeaders)(connection.provider, resolved.credential),
181
+ };
182
+ const body = node.data?.body ? JSON.stringify(node.data.body) : undefined;
183
+ const response = await fetch(endpoint, { method, headers, body });
184
+ if (!response.ok) {
185
+ throw new NodeExecutionError(node.id, node.type, `Node '${node.id}' (${node.type}): integration action failed (${response.statusText}).`);
186
+ }
187
+ dataResults.push(await response.json());
188
+ }
150
189
  }
151
190
  let payload = dataResults.length === 1 ? dataResults[0] : dataResults;
152
191
  // 2. Process layer — chain from streamby out-top → processNode in-process → out-process → ...
@@ -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
  }
@@ -1,4 +1,18 @@
1
- import { IntegrationKind, UserIntegration } from '../types';
1
+ import { IntegrationKind, 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): Promise<ResolvedCredential | null>;
@@ -3,8 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.InvalidCredentialUpdateError = exports.OAuthRefreshError = void 0;
7
+ exports.isOAuthServiceProvider = isOAuthServiceProvider;
6
8
  exports.listUserIntegrations = listUserIntegrations;
7
9
  exports.createUserIntegration = createUserIntegration;
10
+ exports.upsertOAuthServiceIntegration = upsertOAuthServiceIntegration;
8
11
  exports.updateUserIntegration = updateUserIntegration;
9
12
  exports.deleteUserIntegration = deleteUserIntegration;
10
13
  exports.getDecryptedIntegrationCredential = getDecryptedIntegrationCredential;
@@ -12,9 +15,54 @@ exports.getDecryptedIntegrationCredentialById = getDecryptedIntegrationCredentia
12
15
  const crypto_1 = __importDefault(require("crypto"));
13
16
  const manager_1 = require("../models/manager");
14
17
  const encryption_1 = require("../utils/encryption");
18
+ const oauthClient_1 = require("./oauthClient");
19
+ const OAUTH_SERVICE_PROVIDERS = new Set(['jira', 'google']);
20
+ function isOAuthServiceProvider(provider) {
21
+ return OAUTH_SERVICE_PROVIDERS.has(provider);
22
+ }
23
+ // Refresh if the token expires within this window — a small buffer against clock skew and
24
+ // request latency so a caller never receives a token that expires mid-flight.
25
+ const REFRESH_SKEW_MS = 60000;
26
+ class OAuthRefreshError extends Error {
27
+ constructor(provider, integrationId, message) {
28
+ super(message);
29
+ this.provider = provider;
30
+ this.integrationId = integrationId;
31
+ this.name = 'OAuthRefreshError';
32
+ }
33
+ }
34
+ exports.OAuthRefreshError = OAuthRefreshError;
35
+ // Thrown when a client tries to set/overwrite the credential of an OAuth-backed (jira/
36
+ // google) integration directly — those can only be linked/relinked via the OAuth flow.
37
+ class InvalidCredentialUpdateError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = 'InvalidCredentialUpdateError';
41
+ }
42
+ }
43
+ exports.InvalidCredentialUpdateError = InvalidCredentialUpdateError;
15
44
  function encryptCredential(kind, value) {
16
45
  return kind === 'storage' ? (0, encryption_1.encrypt)(JSON.stringify(value)) : (0, encryption_1.encrypt)(String(value));
17
46
  }
47
+ // OAuth tokens never go through encryptCredential/decryptIntegration (those stay unchanged,
48
+ // used only for database/storage/claude's single encryptedCredential field) — each token is
49
+ // encrypted independently via the existing single-string encrypt()/decrypt() primitives.
50
+ function encryptOAuthTokens(tokens) {
51
+ return {
52
+ encryptedAccessToken: (0, encryption_1.encrypt)(tokens.accessToken),
53
+ encryptedRefreshToken: (0, encryption_1.encrypt)(tokens.refreshToken),
54
+ tokenExpiresAt: tokens.expiresAt,
55
+ };
56
+ }
57
+ function decryptOAuthTokens(integration) {
58
+ if (!integration.encryptedAccessToken || !integration.encryptedRefreshToken || !integration.tokenExpiresAt)
59
+ return null;
60
+ return {
61
+ accessToken: (0, encryption_1.decrypt)(integration.encryptedAccessToken),
62
+ refreshToken: (0, encryption_1.decrypt)(integration.encryptedRefreshToken),
63
+ expiresAt: integration.tokenExpiresAt,
64
+ };
65
+ }
18
66
  async function listUserIntegrations(userId) {
19
67
  const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
20
68
  return UserIntegrationModel.find({ userId });
@@ -23,6 +71,12 @@ async function createUserIntegration(userId, input) {
23
71
  if (!(0, encryption_1.isEncryptionKeySet)()) {
24
72
  throw new Error('Encryption key is not set. Cannot create integration.');
25
73
  }
74
+ // Defense in depth — the router rejects direct jira/google creation before this is ever
75
+ // called (see userIntegration.ts route's POST handler), but guard here too rather than
76
+ // silently mis-encrypting an OAuth provider's value into the wrong (single-string) field.
77
+ if (isOAuthServiceProvider(input.provider)) {
78
+ throw new InvalidCredentialUpdateError(`Provider '${input.provider}' requires OAuth — cannot be created with a direct credential.`);
79
+ }
26
80
  const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
27
81
  const now = new Date();
28
82
  const integration = {
@@ -38,6 +92,34 @@ async function createUserIntegration(userId, input) {
38
92
  };
39
93
  return UserIntegrationModel.create(integration);
40
94
  }
95
+ // Creates or updates the OAuth-backed UserIntegration for (userId, provider) — called only
96
+ // from the OAuth callback (oauth.ts), never from the generic CRUD router. Upserts rather
97
+ // than always inserting, so a user re-consenting (e.g. after revoking access on the
98
+ // provider's side) re-links the same integration instead of creating a duplicate.
99
+ async function upsertOAuthServiceIntegration(userId, provider, name, tokens) {
100
+ if (!(0, encryption_1.isEncryptionKeySet)()) {
101
+ throw new Error('Encryption key is not set. Cannot store integration tokens.');
102
+ }
103
+ const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
104
+ const existing = await UserIntegrationModel.findOne({ userId, kind: 'service', provider });
105
+ const encryptedTokens = encryptOAuthTokens(tokens);
106
+ const now = new Date();
107
+ if (existing) {
108
+ const updated = await UserIntegrationModel.update({ id: existing.id, userId }, { ...encryptedTokens, updatedAt: now });
109
+ return updated ?? existing;
110
+ }
111
+ const integration = {
112
+ id: crypto_1.default.randomUUID(),
113
+ userId,
114
+ kind: 'service',
115
+ provider,
116
+ name,
117
+ ...encryptedTokens,
118
+ createdAt: now,
119
+ updatedAt: now,
120
+ };
121
+ return UserIntegrationModel.create(integration);
122
+ }
41
123
  async function updateUserIntegration(userId, integrationId, updates) {
42
124
  const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
43
125
  const existing = await UserIntegrationModel.findOne({ id: integrationId, userId });
@@ -49,6 +131,9 @@ async function updateUserIntegration(userId, integrationId, updates) {
49
131
  if (updates.description !== undefined)
50
132
  $set.description = updates.description;
51
133
  if (updates.credentialValue !== undefined) {
134
+ if (existing.kind === 'service' && isOAuthServiceProvider(existing.provider)) {
135
+ throw new InvalidCredentialUpdateError('OAuth-backed integrations cannot have their credential set directly — reconnect via OAuth.');
136
+ }
52
137
  if (!(0, encryption_1.isEncryptionKeySet)()) {
53
138
  throw new Error('Encryption key is not set. Cannot update integration.');
54
139
  }
@@ -61,26 +146,58 @@ async function deleteUserIntegration(userId, integrationId) {
61
146
  const deletedCount = await UserIntegrationModel.delete({ id: integrationId, userId });
62
147
  return deletedCount > 0;
63
148
  }
64
- async function getDecryptedIntegrationCredential(userId, integrationId) {
149
+ async function getDecryptedIntegrationCredential(userId, integrationId, config) {
65
150
  const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
66
151
  const integration = await UserIntegrationModel.findOne({ id: integrationId, userId });
67
- return decryptIntegration(integration);
152
+ return resolveCredential(integration, config);
68
153
  }
69
154
  // Used when resolving a project connection's `integrationId` at connection-use time — the
70
155
  // project already validated ownership when the connection was created (see project.ts /
71
156
  // dbConnection.ts POST), so there's no userId to filter by here, only the integrationId
72
- // stored on the connection itself.
73
- async function getDecryptedIntegrationCredentialById(integrationId) {
157
+ // stored on the connection itself. `config` is required (not just for OAuth's sake) so a
158
+ // near-expiry jira/google token can be refreshed using the host-injected client
159
+ // credentials in config.oauthProviders.
160
+ async function getDecryptedIntegrationCredentialById(integrationId, config) {
74
161
  const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
75
162
  const integration = await UserIntegrationModel.findOne({ id: integrationId });
76
- return decryptIntegration(integration);
163
+ return resolveCredential(integration, config);
77
164
  }
78
165
  async function decryptIntegration(integration) {
79
- if (!integration)
80
- return null;
81
166
  if (!(0, encryption_1.isEncryptionKeySet)()) {
82
167
  throw new Error('Encryption key is not set. Cannot decrypt integration credential.');
83
168
  }
84
169
  const decrypted = (0, encryption_1.decrypt)(integration.encryptedCredential);
85
170
  return integration.kind === 'storage' ? JSON.parse(decrypted) : decrypted;
86
171
  }
172
+ // OAuth-backed integrations (jira/google) transparently refresh-and-repersist their access
173
+ // token when it's expired or near-expiry, before returning it — callers never see a stale
174
+ // token or have to think about refresh themselves.
175
+ async function resolveCredential(integration, config) {
176
+ if (!integration)
177
+ return null;
178
+ if (integration.kind === 'service' && isOAuthServiceProvider(integration.provider)) {
179
+ const tokens = decryptOAuthTokens(integration);
180
+ if (!tokens) {
181
+ throw new OAuthRefreshError(integration.provider, integration.id, `The ${integration.provider} connection for this integration is incomplete — reconnect it from account integrations.`);
182
+ }
183
+ if (tokens.expiresAt.getTime() > Date.now() + REFRESH_SKEW_MS) {
184
+ return { provider: integration.provider, kind: integration.kind, credential: tokens.accessToken };
185
+ }
186
+ const provider = integration.provider;
187
+ const oauthConfig = config.oauthProviders?.[provider];
188
+ if (!oauthConfig) {
189
+ throw new OAuthRefreshError(integration.provider, integration.id, `OAuth for '${provider}' is not configured on this deployment — cannot refresh the token.`);
190
+ }
191
+ try {
192
+ const refreshed = await (0, oauthClient_1.refreshTokens)(provider, oauthConfig, tokens.refreshToken);
193
+ const encryptedTokens = encryptOAuthTokens(refreshed);
194
+ const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
195
+ await UserIntegrationModel.update({ id: integration.id }, { ...encryptedTokens, updatedAt: new Date() });
196
+ return { provider: integration.provider, kind: integration.kind, credential: refreshed.accessToken };
197
+ }
198
+ catch (e) {
199
+ throw new OAuthRefreshError(integration.provider, integration.id, `The ${integration.provider} connection for this integration has expired — reconnect it from account integrations.`);
200
+ }
201
+ }
202
+ return { provider: integration.provider, kind: integration.kind, credential: await decryptIntegration(integration) };
203
+ }
@@ -76,6 +76,7 @@ export interface ProjectInfo {
76
76
  connections?: Connection[];
77
77
  dbConnections?: DbConnection[];
78
78
  storageConnections?: StorageConnection[];
79
+ integrationConnections?: IntegrationConnection[];
79
80
  pipelines?: PipelineRef[];
80
81
  builtinBackfilledAt?: Date;
81
82
  workflow?: Workflow;
@@ -117,6 +118,13 @@ export interface DatabaseCredential {
117
118
  connectionString?: string;
118
119
  main?: boolean;
119
120
  }
121
+ export interface OAuthClientConfig {
122
+ clientId: string;
123
+ clientSecret: string;
124
+ redirectUri: string;
125
+ successRedirectUrl: string;
126
+ failureRedirectUrl: string;
127
+ }
120
128
  export interface StreamByConfig {
121
129
  storageProviders: StorageProvider[];
122
130
  authProvider: AuthProvider;
@@ -127,6 +135,10 @@ export interface StreamByConfig {
127
135
  server: WebSocketServer;
128
136
  };
129
137
  canUseBuiltin?: (auth: Auth, builtinId: string, kind: 'database' | 'storage') => boolean | Promise<boolean>;
138
+ oauthProviders?: {
139
+ jira?: OAuthClientConfig;
140
+ google?: OAuthClientConfig;
141
+ };
130
142
  }
131
143
  export interface Notification {
132
144
  _id?: string;
@@ -167,15 +179,29 @@ export interface DbConnection {
167
179
  integrationId?: string;
168
180
  source?: 'builtin' | 'integration' | 'manual';
169
181
  }
170
- export type IntegrationKind = 'database' | 'storage';
182
+ export interface IntegrationConnection {
183
+ id: string;
184
+ name: string;
185
+ provider: ServiceProviderType;
186
+ projectId: string;
187
+ createdAt: Date;
188
+ description?: string;
189
+ integrationId: string;
190
+ source: 'integration';
191
+ }
192
+ export type IntegrationKind = 'database' | 'storage' | 'service';
193
+ export type ServiceProviderType = 'claude' | 'jira' | 'google';
171
194
  export interface UserIntegration {
172
195
  id: string;
173
196
  userId: string;
174
197
  kind: IntegrationKind;
175
- provider: ExternalDbType | StorageProviderType;
198
+ provider: ExternalDbType | StorageProviderType | ServiceProviderType;
176
199
  name: string;
177
200
  description?: string;
178
- encryptedCredential: string;
201
+ encryptedCredential?: string;
202
+ encryptedAccessToken?: string;
203
+ encryptedRefreshToken?: string;
204
+ tokenExpiresAt?: Date;
179
205
  createdAt: Date;
180
206
  updatedAt: Date;
181
207
  }
@@ -8,5 +8,11 @@ export declare function sanitizeConnections<T extends {
8
8
  }>(conns: T[] | undefined): (Omit<T, 'encryptedCredential'> & {
9
9
  hasCredential: boolean;
10
10
  })[];
11
+ export declare function sanitizeIntegrationConnection<T extends object>(conn: T): T & {
12
+ hasCredential: true;
13
+ };
14
+ export declare function sanitizeIntegrationConnections<T extends object>(conns: T[] | undefined): (T & {
15
+ hasCredential: true;
16
+ })[];
11
17
  export declare function sanitizeProject(project: any): any;
12
18
  export declare function sanitizeUserIntegration(integration: any): any;
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sanitizeConnection = sanitizeConnection;
4
4
  exports.sanitizeConnections = sanitizeConnections;
5
+ exports.sanitizeIntegrationConnection = sanitizeIntegrationConnection;
6
+ exports.sanitizeIntegrationConnections = sanitizeIntegrationConnections;
5
7
  exports.sanitizeProject = sanitizeProject;
6
8
  exports.sanitizeUserIntegration = sanitizeUserIntegration;
7
9
  function sanitizeConnection(conn) {
@@ -11,6 +13,16 @@ function sanitizeConnection(conn) {
11
13
  function sanitizeConnections(conns) {
12
14
  return (conns ?? []).map(sanitizeConnection);
13
15
  }
16
+ // IntegrationConnection has no encryptedCredential field at all — credentials never live on
17
+ // the connection, only integrationId does, resolved on-demand from the account-level
18
+ // UserIntegration. hasCredential is hardcoded true (never derived from a local field) for
19
+ // API shape parity with the other three connection types.
20
+ function sanitizeIntegrationConnection(conn) {
21
+ return { ...conn, hasCredential: true };
22
+ }
23
+ function sanitizeIntegrationConnections(conns) {
24
+ return (conns ?? []).map(sanitizeIntegrationConnection);
25
+ }
14
26
  function sanitizeProject(project) {
15
27
  if (!project)
16
28
  return project;
@@ -20,14 +32,19 @@ function sanitizeProject(project) {
20
32
  connections: sanitizeConnections(project.connections),
21
33
  storageConnections: sanitizeConnections(project.storageConnections),
22
34
  dbConnections: sanitizeConnections(project.dbConnections),
35
+ integrationConnections: sanitizeIntegrationConnections(project.integrationConnections),
23
36
  distributionConnections: sanitizeConnections(project.distributionConnections),
24
37
  renderFarmConnections: sanitizeConnections(project.renderFarmConnections),
25
38
  exports: project.exports?.map((e) => e && 'encryptedCredential' in e ? sanitizeConnection(e) : e),
26
39
  };
27
40
  }
41
+ // Strips every raw secret an OAuth-backed integration might carry — encryptedCredential
42
+ // (db/storage/claude) plus encryptedAccessToken/encryptedRefreshToken (jira/google).
43
+ // tokenExpiresAt is deliberately kept: it's not secret, and its presence lets a future UI
44
+ // show "reconnect needed" without a decrypt round-trip.
28
45
  function sanitizeUserIntegration(integration) {
29
46
  if (!integration)
30
47
  return integration;
31
- const { encryptedCredential, ...rest } = integration;
48
+ const { encryptedCredential, encryptedAccessToken, encryptedRefreshToken, ...rest } = integration;
32
49
  return rest;
33
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamby-core",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "StreamBy middleware framework for media storage management",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",