@sulala/agent 0.1.16 → 0.1.18
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/dashboard/dist/assets/index-BGp-sVM_.css +1 -0
- package/dashboard/dist/assets/index-BblrA7UI.js +83 -0
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/ver1-04.png +0 -0
- package/dist/agent/loop.d.ts +2 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +10 -0
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/pi-runner.d.ts.map +1 -1
- package/dist/agent/pi-runner.js +2 -0
- package/dist/agent/pi-runner.js.map +1 -1
- package/dist/agent/skills-config.d.ts +1 -1
- package/dist/agent/skills-config.d.ts.map +1 -1
- package/dist/agent/skills-config.js +37 -18
- package/dist/agent/skills-config.js.map +1 -1
- package/dist/agent/skills-watcher.js +1 -1
- package/dist/agent/skills-watcher.js.map +1 -1
- package/dist/agent/tool/spec-loader.d.ts +2 -1
- package/dist/agent/tool/spec-loader.d.ts.map +1 -1
- package/dist/agent/tool/spec-loader.js +19 -1
- package/dist/agent/tool/spec-loader.js.map +1 -1
- package/dist/agent/tools.d.ts +5 -1
- package/dist/agent/tools.d.ts.map +1 -1
- package/dist/agent/tools.integrations.test.js +39 -28
- package/dist/agent/tools.integrations.test.js.map +1 -1
- package/dist/agent/tools.js +20 -7
- package/dist/agent/tools.js.map +1 -1
- package/dist/ai/orchestrator.d.ts.map +1 -1
- package/dist/ai/orchestrator.js +33 -14
- package/dist/ai/orchestrator.js.map +1 -1
- package/dist/channels/discord.d.ts +1 -1
- package/dist/channels/discord.d.ts.map +1 -1
- package/dist/channels/discord.js +5 -2
- package/dist/channels/discord.js.map +1 -1
- package/dist/channels/stripe.d.ts +1 -1
- package/dist/channels/stripe.d.ts.map +1 -1
- package/dist/channels/stripe.js +5 -2
- package/dist/channels/stripe.js.map +1 -1
- package/dist/cli.js +41 -3
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -1
- package/dist/config.js.map +1 -1
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +327 -11
- package/dist/gateway/server.js.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/client.d.ts +5 -0
- package/dist/mcp/client.d.ts.map +1 -0
- package/dist/mcp/client.js +105 -0
- package/dist/mcp/client.js.map +1 -0
- package/dist/mcp/config.d.ts +27 -0
- package/dist/mcp/config.d.ts.map +1 -0
- package/dist/mcp/config.js +71 -0
- package/dist/mcp/config.js.map +1 -0
- package/package.json +5 -4
- package/src/index.ts +7 -2
- package/dashboard/dist/assets/index-DegBJNv6.css +0 -1
- package/dashboard/dist/assets/index-pVHpAj3h.js +0 -83
package/dist/gateway/server.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { createServer } from 'http';
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
-
import { join } from 'path';
|
|
4
|
+
import { join, resolve, sep } from 'path';
|
|
5
5
|
import { randomBytes } from 'crypto';
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
7
|
import cors from 'cors';
|
|
8
|
-
import { config, getSulalaEnvPath, getPortalGatewayBase, getEffectivePortalApiKey } from '../config.js';
|
|
8
|
+
import { config, getSulalaEnvPath, getPortalGatewayBase, getEffectivePortalApiKey, getSulalaEnvKey } from '../config.js';
|
|
9
9
|
import { readOnboardEnvKeys, writeOnboardEnvKeys } from '../onboard-env.js';
|
|
10
10
|
import { initDb, getDb, log, insertTask, getFileStates, updateTaskStatus, setTaskPendingForRetry, getOrCreateAgentSession, getAgentSessionById, getAgentMessages, appendAgentMessage, updateAgentMessageToolResult, listAgentMemories, listAgentMemoryScopeKeys, listAgentSessions, listScheduledJobs, getScheduledJob, insertScheduledJob, updateScheduledJob, deleteScheduledJob, getTasksForJob, getChannelConfig, setChannelConfig, } from '../db/index.js';
|
|
11
11
|
import { scheduleCronById, unscheduleJob } from '../scheduler/cron.js';
|
|
@@ -27,6 +27,8 @@ import { listPendingActions, getPendingAction, getPendingActionForReplay, setPen
|
|
|
27
27
|
import { isOllamaReachable, startOllamaServeForApi, runOllamaInstall, setPullProgressCallback, pullOllamaModel } from '../ollama-setup.js';
|
|
28
28
|
import { getSystemCapabilities } from '../system-capabilities.js';
|
|
29
29
|
import { getPackageRoot } from '../onboard.js';
|
|
30
|
+
import { getMcpConfigForDisplay, writeMcpServersConfig, getMcpServersConfig } from '../mcp/config.js';
|
|
31
|
+
import { refreshMcpTools } from '../mcp/client.js';
|
|
30
32
|
const projectRoot = getPackageRoot();
|
|
31
33
|
const dashboardDist = join(projectRoot, 'dashboard', 'dist');
|
|
32
34
|
const registryDir = join(projectRoot, 'registry');
|
|
@@ -36,6 +38,8 @@ function rateLimitMiddleware(req, res, next) {
|
|
|
36
38
|
return next();
|
|
37
39
|
if (req.path === '/health')
|
|
38
40
|
return next();
|
|
41
|
+
if (req.path === '/.well-known/oauth-protected-resource')
|
|
42
|
+
return next();
|
|
39
43
|
// Don't rate-limit read-only or bootstrap endpoints the dashboard calls often
|
|
40
44
|
if (req.path.startsWith('/api/onboard'))
|
|
41
45
|
return next();
|
|
@@ -82,7 +86,12 @@ function getStorePublishBaseUrl() {
|
|
|
82
86
|
export function createGateway(appMount = null) {
|
|
83
87
|
const app = appMount || express();
|
|
84
88
|
app.use(cors());
|
|
85
|
-
|
|
89
|
+
// Skip default JSON body parser for /api/upload so the route can use a 260mb limit for video uploads
|
|
90
|
+
app.use((req, res, next) => {
|
|
91
|
+
if (req.path === '/api/upload' && req.method === 'POST')
|
|
92
|
+
return next();
|
|
93
|
+
return express.json()(req, res, next);
|
|
94
|
+
});
|
|
86
95
|
app.use(rateLimitMiddleware);
|
|
87
96
|
try {
|
|
88
97
|
const skills = listSkills(config, { includeDisabled: true });
|
|
@@ -95,6 +104,8 @@ export function createGateway(appMount = null) {
|
|
|
95
104
|
app.use((req, res, next) => {
|
|
96
105
|
if (req.path === '/health')
|
|
97
106
|
return next();
|
|
107
|
+
if (req.path === '/.well-known/oauth-protected-resource')
|
|
108
|
+
return next();
|
|
98
109
|
if (req.path === '/onboard' || req.path.startsWith('/api/onboard') || req.path.startsWith('/api/ollama'))
|
|
99
110
|
return next();
|
|
100
111
|
const key = req.headers['x-api-key'] || req.query.api_key;
|
|
@@ -173,6 +184,32 @@ export function createGateway(appMount = null) {
|
|
|
173
184
|
app.get('/health', (_req, res) => {
|
|
174
185
|
res.json({ status: 'ok', service: 'sulala-gateway' });
|
|
175
186
|
});
|
|
187
|
+
/** MCP OAuth 2.1 (RFC 9728): protected resource metadata for ChatGPT Apps SDK. Served without auth so clients can discover. */
|
|
188
|
+
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
|
|
189
|
+
const enabled = (getSulalaEnvKey('MCP_OAUTH_ENABLED') || '').toLowerCase();
|
|
190
|
+
if (enabled !== '1' && enabled !== 'true') {
|
|
191
|
+
res.status(404).json({ error: 'MCP OAuth not enabled' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const resourceUrl = (getSulalaEnvKey('MCP_OAUTH_RESOURCE_URL') || '').trim();
|
|
195
|
+
const authServer = (getSulalaEnvKey('MCP_OAUTH_AUTHORIZATION_SERVER') || '').trim();
|
|
196
|
+
if (!resourceUrl || !authServer) {
|
|
197
|
+
res.status(503).setHeader('Content-Type', 'application/json').json({
|
|
198
|
+
error: 'MCP OAuth not configured',
|
|
199
|
+
detail: 'Set MCP_OAUTH_RESOURCE_URL and MCP_OAUTH_AUTHORIZATION_SERVER (e.g. Auth0 tenant URL).',
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const scopesRaw = (getSulalaEnvKey('MCP_OAUTH_SCOPES_SUPPORTED') || '').trim();
|
|
204
|
+
const scopes_supported = scopesRaw ? scopesRaw.split(',').map((s) => s.trim()).filter(Boolean) : ['openid'];
|
|
205
|
+
const doc = {
|
|
206
|
+
resource: resourceUrl.replace(/\/$/, ''),
|
|
207
|
+
authorization_servers: [authServer.replace(/\/$/, '')],
|
|
208
|
+
scopes_supported,
|
|
209
|
+
resource_documentation: resourceUrl.replace(/\/$/, '') + '/onboard',
|
|
210
|
+
};
|
|
211
|
+
res.setHeader('Content-Type', 'application/json').json(doc);
|
|
212
|
+
});
|
|
176
213
|
/** Onboard: check if onboarding is complete (first-launch detection). */
|
|
177
214
|
app.get('/api/onboard/status', (_req, res) => {
|
|
178
215
|
try {
|
|
@@ -569,6 +606,65 @@ export function createGateway(appMount = null) {
|
|
|
569
606
|
res.status(500).json({ error: e.message });
|
|
570
607
|
}
|
|
571
608
|
});
|
|
609
|
+
/** GET /api/mcp/config — MCP servers config for dashboard (env values redacted). */
|
|
610
|
+
app.get('/api/mcp/config', (_req, res) => {
|
|
611
|
+
try {
|
|
612
|
+
res.json(getMcpConfigForDisplay());
|
|
613
|
+
}
|
|
614
|
+
catch (e) {
|
|
615
|
+
log('gateway', 'error', e.message);
|
|
616
|
+
res.status(500).json({ error: e.message });
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
/** PUT /api/mcp/config — Save MCP servers (dashboard). Body: { servers }. Env values "***" are preserved from existing config. */
|
|
620
|
+
app.put('/api/mcp/config', async (req, res) => {
|
|
621
|
+
try {
|
|
622
|
+
const body = req.body;
|
|
623
|
+
if (!body || !Array.isArray(body.servers)) {
|
|
624
|
+
res.status(400).json({ error: 'Body must include servers array' });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const current = getMcpServersConfig();
|
|
628
|
+
const currentByName = new Map(current.map((s) => [s.name, s]));
|
|
629
|
+
const merged = body.servers.map((raw) => {
|
|
630
|
+
const o = raw;
|
|
631
|
+
const name = typeof o.name === 'string' ? o.name.trim() : '';
|
|
632
|
+
const command = typeof o.command === 'string' ? o.command.trim() : '';
|
|
633
|
+
if (!name || !command)
|
|
634
|
+
return null;
|
|
635
|
+
const args = Array.isArray(o.args) ? o.args.map((a) => String(a)) : undefined;
|
|
636
|
+
const envRaw = o.env && typeof o.env === 'object' && !Array.isArray(o.env) ? o.env : undefined;
|
|
637
|
+
const existing = currentByName.get(name);
|
|
638
|
+
const env = {};
|
|
639
|
+
if (envRaw) {
|
|
640
|
+
for (const [k, v] of Object.entries(envRaw)) {
|
|
641
|
+
if (typeof k !== 'string')
|
|
642
|
+
continue;
|
|
643
|
+
const val = typeof v === 'string' ? v : v != null ? String(v) : '';
|
|
644
|
+
if (val === '***' && existing?.env?.[k] != null)
|
|
645
|
+
env[k] = existing.env[k];
|
|
646
|
+
else if (val !== '***')
|
|
647
|
+
env[k] = val;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
else if (existing?.env)
|
|
651
|
+
Object.assign(env, existing.env);
|
|
652
|
+
const entry = { name, command };
|
|
653
|
+
if (args?.length)
|
|
654
|
+
entry.args = args;
|
|
655
|
+
if (Object.keys(env).length)
|
|
656
|
+
entry.env = env;
|
|
657
|
+
return entry;
|
|
658
|
+
}).filter((c) => c !== null);
|
|
659
|
+
writeMcpServersConfig(merged);
|
|
660
|
+
await refreshMcpTools();
|
|
661
|
+
res.json(getMcpConfigForDisplay());
|
|
662
|
+
}
|
|
663
|
+
catch (e) {
|
|
664
|
+
log('gateway', 'error', e.message);
|
|
665
|
+
res.status(500).json({ error: e.message });
|
|
666
|
+
}
|
|
667
|
+
});
|
|
572
668
|
/** DELETE /api/integrations/connections/:id — When Portal configured, disconnect via Portal. */
|
|
573
669
|
app.delete('/api/integrations/connections/:id', async (req, res) => {
|
|
574
670
|
try {
|
|
@@ -719,6 +815,10 @@ export function createGateway(appMount = null) {
|
|
|
719
815
|
const directSet = !!config.integrationsUrl?.trim();
|
|
720
816
|
const integrationsMode = portalSet ? 'portal' : directSet ? 'direct' : null;
|
|
721
817
|
const portalOAuthClientId = (process.env.PORTAL_OAUTH_CLIENT_ID || '').trim();
|
|
818
|
+
const mcpOAuthEnabled = ((getSulalaEnvKey('MCP_OAUTH_ENABLED') || '').toLowerCase() === '1' || (getSulalaEnvKey('MCP_OAUTH_ENABLED') || '').toLowerCase() === 'true');
|
|
819
|
+
const mcpOAuthResourceUrl = (getSulalaEnvKey('MCP_OAUTH_RESOURCE_URL') || '').trim();
|
|
820
|
+
const mcpOAuthAuthServer = (getSulalaEnvKey('MCP_OAUTH_AUTHORIZATION_SERVER') || '').trim();
|
|
821
|
+
const mcpOAuthScopes = (getSulalaEnvKey('MCP_OAUTH_SCOPES_SUPPORTED') || '').trim();
|
|
722
822
|
res.json({
|
|
723
823
|
watchFolders: config.watchFolders || [],
|
|
724
824
|
agentUsePi: config.agentUsePi,
|
|
@@ -730,6 +830,18 @@ export function createGateway(appMount = null) {
|
|
|
730
830
|
portalGatewayUrl: portalUrl || config.portalGatewayUrl || null,
|
|
731
831
|
/** When set, dashboard can show "Connect with Sulala (OAuth)" and use /api/oauth/connect-url. */
|
|
732
832
|
portalOAuthConnectAvailable: !!(portalUrl && portalOAuthClientId),
|
|
833
|
+
/** ChatGPT Apps SDK / MCP OAuth 2.1: config for onboarding and settings. */
|
|
834
|
+
chatgptOAuth: {
|
|
835
|
+
enabled: mcpOAuthEnabled,
|
|
836
|
+
resourceUrl: mcpOAuthResourceUrl || null,
|
|
837
|
+
authorizationServer: mcpOAuthAuthServer || null,
|
|
838
|
+
scopesSupported: mcpOAuthScopes ? mcpOAuthScopes.split(',').map((s) => s.trim()).filter(Boolean) : [],
|
|
839
|
+
/** Redirect URIs to allowlist in your IdP (Auth0, Stytch, etc.). */
|
|
840
|
+
redirectUrisHint: [
|
|
841
|
+
'https://chatgpt.com/connector/oauth/{callback_id}',
|
|
842
|
+
'https://platform.openai.com/apps-manage/oauth',
|
|
843
|
+
],
|
|
844
|
+
},
|
|
733
845
|
aiProviders: [
|
|
734
846
|
{ id: 'openai', label: 'OpenAI', defaultModel: process.env.AI_OPENAI_DEFAULT_MODEL || 'gpt-4o-mini' },
|
|
735
847
|
{ id: 'openrouter', label: 'OpenRouter', defaultModel: process.env.AI_OPENROUTER_DEFAULT_MODEL || 'openai/gpt-4o-mini' },
|
|
@@ -1349,7 +1461,7 @@ export function createGateway(appMount = null) {
|
|
|
1349
1461
|
const { message, system_prompt, provider, model, max_tokens, timeout_ms, use_pi, required_integrations, attachment_urls } = req.body || {};
|
|
1350
1462
|
const urls = Array.isArray(attachment_urls) ? attachment_urls.filter((u) => typeof u === 'string' && u.trim()) : [];
|
|
1351
1463
|
const userMessageText = typeof message === 'string' ? message : '';
|
|
1352
|
-
const fullUserMessage = urls.length > 0 ? `${userMessageText}\n\n[Attached media
|
|
1464
|
+
const fullUserMessage = urls.length > 0 ? `${userMessageText}\n\n[Attached media — use these URLs when posting images (e.g. Facebook) or when uploading video to YouTube (pass as video_url to youtube_upload): ${urls.join(', ')}]` : userMessageText;
|
|
1353
1465
|
const required = Array.isArray(required_integrations)
|
|
1354
1466
|
? required_integrations.filter((k) => typeof k === 'string' && k.trim())
|
|
1355
1467
|
: [];
|
|
@@ -1452,7 +1564,7 @@ export function createGateway(appMount = null) {
|
|
|
1452
1564
|
const isContinue = continueAfterApproval === true || continueAfterApproval === 'true';
|
|
1453
1565
|
const streamUrls = Array.isArray(streamAttachmentUrls) ? streamAttachmentUrls.filter((u) => typeof u === 'string' && u.trim()) : [];
|
|
1454
1566
|
const streamMsg = typeof message === 'string' ? message : '';
|
|
1455
|
-
const streamFullMessage = streamUrls.length > 0 ? `${streamMsg}\n\n[Attached media
|
|
1567
|
+
const streamFullMessage = streamUrls.length > 0 ? `${streamMsg}\n\n[Attached media — use these URLs when posting images (e.g. Facebook) or when uploading video to YouTube (pass as video_url to youtube_upload): ${streamUrls.join(', ')}]` : streamMsg;
|
|
1456
1568
|
const userMessage = isContinue ? null : (streamFullMessage || null);
|
|
1457
1569
|
try {
|
|
1458
1570
|
const runResult = await withSessionLock(id, () => runAgentTurnStream({
|
|
@@ -1812,10 +1924,11 @@ export function createGateway(appMount = null) {
|
|
|
1812
1924
|
res.status(500).json({ error: e.message });
|
|
1813
1925
|
}
|
|
1814
1926
|
});
|
|
1815
|
-
/** Chat attachments: upload image/
|
|
1927
|
+
/** Chat attachments: upload image/video, get a URL the agent can use (e.g. Facebook photo posts, YouTube video upload). */
|
|
1816
1928
|
const uploadsDir = join(projectRoot, 'uploads');
|
|
1817
|
-
const ALLOWED_EXT = /\.(jpg|jpeg|png|gif|webp)$/i;
|
|
1818
|
-
|
|
1929
|
+
const ALLOWED_EXT = /\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|m4v|avi)$/i;
|
|
1930
|
+
const UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MB (matches YouTube upload limit)
|
|
1931
|
+
app.post('/api/upload', express.json({ limit: '260mb' }), (req, res) => {
|
|
1819
1932
|
try {
|
|
1820
1933
|
const { filename, data } = req.body || {};
|
|
1821
1934
|
if (typeof data !== 'string') {
|
|
@@ -1830,8 +1943,8 @@ export function createGateway(appMount = null) {
|
|
|
1830
1943
|
mkdirSync(uploadsDir, { recursive: true });
|
|
1831
1944
|
const path = join(uploadsDir, name);
|
|
1832
1945
|
const buf = Buffer.from(data, 'base64');
|
|
1833
|
-
if (buf.length >
|
|
1834
|
-
res.status(400).json({ error:
|
|
1946
|
+
if (buf.length > UPLOAD_MAX_BYTES) {
|
|
1947
|
+
res.status(400).json({ error: `File too large (max ${UPLOAD_MAX_BYTES / (1024 * 1024)} MB)` });
|
|
1835
1948
|
return;
|
|
1836
1949
|
}
|
|
1837
1950
|
writeFileSync(path, buf);
|
|
@@ -1847,7 +1960,7 @@ export function createGateway(appMount = null) {
|
|
|
1847
1960
|
});
|
|
1848
1961
|
app.get('/api/uploads/:name', (req, res) => {
|
|
1849
1962
|
const name = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name;
|
|
1850
|
-
if (!name || !/^[a-f0-9]{16}\.(jpg|jpeg|png|gif|webp)$/i.test(name)) {
|
|
1963
|
+
if (!name || !/^[a-f0-9]{16}\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|m4v|avi)$/i.test(name)) {
|
|
1851
1964
|
res.status(400).json({ error: 'Invalid upload name' });
|
|
1852
1965
|
return;
|
|
1853
1966
|
}
|
|
@@ -1861,6 +1974,209 @@ export function createGateway(appMount = null) {
|
|
|
1861
1974
|
res.status(500).json({ error: err.message });
|
|
1862
1975
|
});
|
|
1863
1976
|
});
|
|
1977
|
+
/** General media upload: one endpoint for all destinations (youtube, drive, etc.). Hub provides auth only; file is resolved locally and uploaded from gateway. */
|
|
1978
|
+
const MEDIA_UPLOAD_MAX_BYTES = 256 * 1024 * 1024;
|
|
1979
|
+
const UPLOADS_NAME_REGEX = /^[a-f0-9]{16}\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|m4v|avi)$/i;
|
|
1980
|
+
const allowedDirsForPath = () => {
|
|
1981
|
+
const dirs = [resolve(uploadsDir)];
|
|
1982
|
+
if (config.agentWorkspaceRoot)
|
|
1983
|
+
dirs.push(resolve(process.cwd(), config.agentWorkspaceRoot));
|
|
1984
|
+
for (const w of config.watchFolders || []) {
|
|
1985
|
+
const p = resolve(w);
|
|
1986
|
+
if (p && !dirs.includes(p))
|
|
1987
|
+
dirs.push(p);
|
|
1988
|
+
}
|
|
1989
|
+
return dirs;
|
|
1990
|
+
};
|
|
1991
|
+
const isPathAllowed = (filePath) => {
|
|
1992
|
+
const abs = resolve(filePath);
|
|
1993
|
+
for (const dir of allowedDirsForPath()) {
|
|
1994
|
+
const d = resolve(dir);
|
|
1995
|
+
if (abs === d || abs.startsWith(d + sep))
|
|
1996
|
+
return true;
|
|
1997
|
+
}
|
|
1998
|
+
return false;
|
|
1999
|
+
};
|
|
2000
|
+
const getConnectionToken = async (connectionId) => {
|
|
2001
|
+
const portalBase = getPortalGatewayBase();
|
|
2002
|
+
const portalKey = getEffectivePortalApiKey();
|
|
2003
|
+
const integrationsBase = config.integrationsUrl?.replace(/\/$/, '');
|
|
2004
|
+
const integrationsSecret = (process.env.INTEGRATIONS_API_SECRET || '').trim();
|
|
2005
|
+
const useUrl = integrationsBase && integrationsSecret
|
|
2006
|
+
? `${integrationsBase}/connections/${encodeURIComponent(connectionId)}/use`
|
|
2007
|
+
: portalBase && portalKey
|
|
2008
|
+
? `${portalBase.replace(/\/$/, '')}/connections/${encodeURIComponent(connectionId)}/use`
|
|
2009
|
+
: null;
|
|
2010
|
+
if (!useUrl)
|
|
2011
|
+
return { ok: false, status: 503, error: 'Set PORTAL_GATEWAY_URL and PORTAL_API_KEY (or INTEGRATIONS_URL and INTEGRATIONS_API_SECRET).' };
|
|
2012
|
+
const useRes = await fetch(useUrl, {
|
|
2013
|
+
method: 'POST',
|
|
2014
|
+
headers: {
|
|
2015
|
+
'Content-Type': 'application/json',
|
|
2016
|
+
...(integrationsBase && integrationsSecret
|
|
2017
|
+
? { Authorization: `Bearer ${integrationsSecret}`, 'X-Integrations-Api-Secret': integrationsSecret }
|
|
2018
|
+
: { Authorization: `Bearer ${portalKey}` }),
|
|
2019
|
+
},
|
|
2020
|
+
});
|
|
2021
|
+
const useText = await useRes.text();
|
|
2022
|
+
if (!useRes.ok) {
|
|
2023
|
+
let errMsg = useText || 'Failed to get token';
|
|
2024
|
+
try {
|
|
2025
|
+
const parsed = JSON.parse(useText);
|
|
2026
|
+
if (typeof parsed?.error === 'string')
|
|
2027
|
+
errMsg = parsed.error;
|
|
2028
|
+
}
|
|
2029
|
+
catch {
|
|
2030
|
+
// keep errMsg as useText
|
|
2031
|
+
}
|
|
2032
|
+
if (useRes.status === 404) {
|
|
2033
|
+
errMsg += ' Ensure the integration is connected in the Portal for the same account that owns the API key (Settings → API Keys), and use the connection_id from list_integrations_connections.';
|
|
2034
|
+
}
|
|
2035
|
+
return { ok: false, status: useRes.status, error: errMsg };
|
|
2036
|
+
}
|
|
2037
|
+
let useJson;
|
|
2038
|
+
try {
|
|
2039
|
+
useJson = JSON.parse(useText);
|
|
2040
|
+
}
|
|
2041
|
+
catch {
|
|
2042
|
+
return { ok: false, status: 502, error: 'Invalid token response' };
|
|
2043
|
+
}
|
|
2044
|
+
if (!useJson?.accessToken || typeof useJson.accessToken !== 'string') {
|
|
2045
|
+
return { ok: false, status: 502, error: 'Token response missing accessToken' };
|
|
2046
|
+
}
|
|
2047
|
+
return { ok: true, accessToken: useJson.accessToken };
|
|
2048
|
+
};
|
|
2049
|
+
const resolveFileFromRef = async (fileUrl, filePath) => {
|
|
2050
|
+
const contentType = 'application/octet-stream';
|
|
2051
|
+
if (filePath && isPathAllowed(filePath) && existsSync(filePath)) {
|
|
2052
|
+
return { buf: readFileSync(filePath), contentType };
|
|
2053
|
+
}
|
|
2054
|
+
if (filePath)
|
|
2055
|
+
throw new Error('file_path must be under uploads, workspace, or a watch folder');
|
|
2056
|
+
if (!fileUrl)
|
|
2057
|
+
throw new Error('Body must include file_url or file_path');
|
|
2058
|
+
try {
|
|
2059
|
+
const urlObj = new URL(fileUrl);
|
|
2060
|
+
const name = urlObj.pathname.replace(/^\/api\/uploads\//, '').split('/')[0] || '';
|
|
2061
|
+
if (urlObj.pathname.startsWith('/api/uploads/') && UPLOADS_NAME_REGEX.test(name)) {
|
|
2062
|
+
const path = join(uploadsDir, name);
|
|
2063
|
+
if (existsSync(path))
|
|
2064
|
+
return { buf: readFileSync(path), contentType };
|
|
2065
|
+
throw new Error('Upload file not found; it may have expired');
|
|
2066
|
+
}
|
|
2067
|
+
const fileRes = await fetch(fileUrl, { method: 'GET' });
|
|
2068
|
+
if (!fileRes.ok)
|
|
2069
|
+
throw new Error(`Failed to fetch file: ${fileRes.status}`);
|
|
2070
|
+
const buf = Buffer.from(await fileRes.arrayBuffer());
|
|
2071
|
+
const ct = (fileRes.headers.get('content-type') || contentType).split(';')[0].trim();
|
|
2072
|
+
return { buf, contentType: ct };
|
|
2073
|
+
}
|
|
2074
|
+
catch (e) {
|
|
2075
|
+
throw new Error(e.message || 'Invalid file_url or fetch failed');
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
const handleUploadProxy = async (req, res) => {
|
|
2079
|
+
try {
|
|
2080
|
+
const destination = typeof req.body?.destination === 'string' ? req.body.destination.trim().toLowerCase() : '';
|
|
2081
|
+
const connectionId = typeof req.body?.connection_id === 'string' ? req.body.connection_id.trim() : '';
|
|
2082
|
+
const fileUrl = (typeof req.body?.file_url === 'string' ? req.body.file_url.trim() : '') ||
|
|
2083
|
+
(typeof req.body?.video_url === 'string' ? req.body.video_url.trim() : '');
|
|
2084
|
+
const filePath = (typeof req.body?.file_path === 'string' ? req.body.file_path.trim() : '') ||
|
|
2085
|
+
(typeof req.body?.video_path === 'string' ? req.body.video_path.trim() : '');
|
|
2086
|
+
const metadata = (req.body?.metadata && typeof req.body.metadata === 'object') ? req.body.metadata : {};
|
|
2087
|
+
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : (typeof metadata.title === 'string' ? metadata.title : '');
|
|
2088
|
+
const description = typeof req.body?.description === 'string' ? req.body.description.trim() : (typeof metadata.description === 'string' ? metadata.description : '');
|
|
2089
|
+
const privacyStatus = ['public', 'private', 'unlisted'].includes(req.body?.privacyStatus || metadata.privacyStatus || '')
|
|
2090
|
+
? (req.body?.privacyStatus || metadata.privacyStatus || 'private')
|
|
2091
|
+
: 'private';
|
|
2092
|
+
if (!destination || !connectionId) {
|
|
2093
|
+
res.status(400).json({ error: 'Body must include destination and connection_id' });
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
if (!filePath && !fileUrl) {
|
|
2097
|
+
res.status(400).json({ error: 'Body must include file_url or file_path (or video_url/video_path for youtube)' });
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
const { buf, contentType } = await resolveFileFromRef(fileUrl, filePath);
|
|
2101
|
+
if (buf.length > MEDIA_UPLOAD_MAX_BYTES) {
|
|
2102
|
+
res.status(400).json({ error: `File too large (max ${MEDIA_UPLOAD_MAX_BYTES / (1024 * 1024)} MB)` });
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
const tokenResult = await getConnectionToken(connectionId);
|
|
2106
|
+
if (!tokenResult.ok) {
|
|
2107
|
+
res.status(tokenResult.status).json({ error: tokenResult.error });
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
const accessToken = tokenResult.accessToken;
|
|
2111
|
+
if (destination === 'youtube') {
|
|
2112
|
+
const ytTitle = title || (typeof metadata.title === 'string' ? metadata.title : '');
|
|
2113
|
+
if (!ytTitle) {
|
|
2114
|
+
res.status(400).json({ error: 'YouTube upload requires title (or metadata.title)' });
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const initRes = await fetch('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status', {
|
|
2118
|
+
method: 'POST',
|
|
2119
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
|
2120
|
+
body: JSON.stringify({
|
|
2121
|
+
snippet: { title: ytTitle, description: description || undefined },
|
|
2122
|
+
status: { privacyStatus },
|
|
2123
|
+
}),
|
|
2124
|
+
});
|
|
2125
|
+
if (!initRes.ok) {
|
|
2126
|
+
const errText = await initRes.text();
|
|
2127
|
+
log('gateway', 'error', 'YouTube init upload failed', { status: initRes.status, detail: errText.slice(0, 200) });
|
|
2128
|
+
res.status(initRes.status === 401 ? 401 : 502).json({ error: 'YouTube upload init failed', detail: errText.slice(0, 200) });
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
const uploadUrl = initRes.headers.get('Location');
|
|
2132
|
+
if (!uploadUrl) {
|
|
2133
|
+
res.status(502).json({ error: 'YouTube did not return upload URL' });
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
const putRes = await fetch(uploadUrl, {
|
|
2137
|
+
method: 'PUT',
|
|
2138
|
+
headers: { 'Content-Length': String(buf.length), 'Content-Type': contentType },
|
|
2139
|
+
body: new Uint8Array(buf),
|
|
2140
|
+
});
|
|
2141
|
+
if (!putRes.ok) {
|
|
2142
|
+
const errText = await putRes.text();
|
|
2143
|
+
log('gateway', 'error', 'YouTube PUT upload failed', { status: putRes.status, detail: errText.slice(0, 200) });
|
|
2144
|
+
res.status(502).json({ error: 'YouTube upload failed', detail: errText.slice(0, 200) });
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
const putJson = (await putRes.json());
|
|
2148
|
+
const videoId = putJson?.id;
|
|
2149
|
+
if (!videoId) {
|
|
2150
|
+
res.status(502).json({ error: 'YouTube did not return video id' });
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
return res.json({
|
|
2154
|
+
ok: true,
|
|
2155
|
+
id: videoId,
|
|
2156
|
+
link: `https://www.youtube.com/watch?v=${videoId}`,
|
|
2157
|
+
message: 'Video uploaded to YouTube successfully.',
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
if (destination === 'drive') {
|
|
2161
|
+
res.status(501).json({ error: 'Drive upload not implemented yet; use destination youtube for now.' });
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
res.status(400).json({ error: `Unknown destination: ${destination}. Use youtube or drive.` });
|
|
2165
|
+
}
|
|
2166
|
+
catch (e) {
|
|
2167
|
+
const msg = e.message;
|
|
2168
|
+
log('gateway', 'error', msg);
|
|
2169
|
+
if (msg.includes('Set PORTAL') || msg.includes('Token'))
|
|
2170
|
+
res.status(503).json({ error: msg });
|
|
2171
|
+
else
|
|
2172
|
+
res.status(500).json({ error: msg });
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
app.post('/api/upload-proxy', handleUploadProxy);
|
|
2176
|
+
app.post('/api/youtube-upload-proxy', (req, res) => {
|
|
2177
|
+
req.body.destination = 'youtube';
|
|
2178
|
+
return handleUploadProxy(req, res);
|
|
2179
|
+
});
|
|
1864
2180
|
if (existsSync(dashboardDist)) {
|
|
1865
2181
|
app.use(express.static(dashboardDist));
|
|
1866
2182
|
// Explicitly serve dashboard (new step-by-step OnboardingFlow) for /onboard so URL stays /onboard
|