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.
- package/dist/middleware/createRouter.js +2 -0
- package/dist/middleware/routes/oauth.d.ts +3 -0
- package/dist/middleware/routes/oauth.js +101 -0
- package/dist/middleware/routes/project.js +16 -6
- package/dist/middleware/routes/userIntegration.js +17 -2
- package/dist/services/connectionResolver.js +13 -8
- package/dist/services/executor.js +39 -0
- 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/userIntegration.d.ts +23 -3
- package/dist/services/userIntegration.js +124 -7
- package/dist/types/index.d.ts +29 -3
- package/dist/utils/sanitize.d.ts +6 -0
- package/dist/utils/sanitize.js +18 -1
- package/package.json +1 -1
|
@@ -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,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
|
-
:
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
51
|
-
if (!
|
|
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
|
|
108
|
-
if (!
|
|
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(
|
|
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: '
|
|
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
|
}
|
|
@@ -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
|
|
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): 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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
201
|
+
encryptedCredential?: string;
|
|
202
|
+
encryptedAccessToken?: string;
|
|
203
|
+
encryptedRefreshToken?: string;
|
|
204
|
+
tokenExpiresAt?: Date;
|
|
179
205
|
createdAt: Date;
|
|
180
206
|
updatedAt: Date;
|
|
181
207
|
}
|
package/dist/utils/sanitize.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/sanitize.js
CHANGED
|
@@ -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
|
}
|