@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.
Files changed (61) hide show
  1. package/dashboard/dist/assets/index-BGp-sVM_.css +1 -0
  2. package/dashboard/dist/assets/index-BblrA7UI.js +83 -0
  3. package/dashboard/dist/index.html +2 -2
  4. package/dashboard/dist/ver1-04.png +0 -0
  5. package/dist/agent/loop.d.ts +2 -0
  6. package/dist/agent/loop.d.ts.map +1 -1
  7. package/dist/agent/loop.js +10 -0
  8. package/dist/agent/loop.js.map +1 -1
  9. package/dist/agent/pi-runner.d.ts.map +1 -1
  10. package/dist/agent/pi-runner.js +2 -0
  11. package/dist/agent/pi-runner.js.map +1 -1
  12. package/dist/agent/skills-config.d.ts +1 -1
  13. package/dist/agent/skills-config.d.ts.map +1 -1
  14. package/dist/agent/skills-config.js +37 -18
  15. package/dist/agent/skills-config.js.map +1 -1
  16. package/dist/agent/skills-watcher.js +1 -1
  17. package/dist/agent/skills-watcher.js.map +1 -1
  18. package/dist/agent/tool/spec-loader.d.ts +2 -1
  19. package/dist/agent/tool/spec-loader.d.ts.map +1 -1
  20. package/dist/agent/tool/spec-loader.js +19 -1
  21. package/dist/agent/tool/spec-loader.js.map +1 -1
  22. package/dist/agent/tools.d.ts +5 -1
  23. package/dist/agent/tools.d.ts.map +1 -1
  24. package/dist/agent/tools.integrations.test.js +39 -28
  25. package/dist/agent/tools.integrations.test.js.map +1 -1
  26. package/dist/agent/tools.js +20 -7
  27. package/dist/agent/tools.js.map +1 -1
  28. package/dist/ai/orchestrator.d.ts.map +1 -1
  29. package/dist/ai/orchestrator.js +33 -14
  30. package/dist/ai/orchestrator.js.map +1 -1
  31. package/dist/channels/discord.d.ts +1 -1
  32. package/dist/channels/discord.d.ts.map +1 -1
  33. package/dist/channels/discord.js +5 -2
  34. package/dist/channels/discord.js.map +1 -1
  35. package/dist/channels/stripe.d.ts +1 -1
  36. package/dist/channels/stripe.d.ts.map +1 -1
  37. package/dist/channels/stripe.js +5 -2
  38. package/dist/channels/stripe.js.map +1 -1
  39. package/dist/cli.js +41 -3
  40. package/dist/cli.js.map +1 -1
  41. package/dist/config.d.ts +2 -0
  42. package/dist/config.d.ts.map +1 -1
  43. package/dist/config.js +7 -1
  44. package/dist/config.js.map +1 -1
  45. package/dist/gateway/server.d.ts.map +1 -1
  46. package/dist/gateway/server.js +327 -11
  47. package/dist/gateway/server.js.map +1 -1
  48. package/dist/index.js +7 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/mcp/client.d.ts +5 -0
  51. package/dist/mcp/client.d.ts.map +1 -0
  52. package/dist/mcp/client.js +105 -0
  53. package/dist/mcp/client.js.map +1 -0
  54. package/dist/mcp/config.d.ts +27 -0
  55. package/dist/mcp/config.d.ts.map +1 -0
  56. package/dist/mcp/config.js +71 -0
  57. package/dist/mcp/config.js.map +1 -0
  58. package/package.json +5 -4
  59. package/src/index.ts +7 -2
  60. package/dashboard/dist/assets/index-DegBJNv6.css +0 -1
  61. package/dashboard/dist/assets/index-pVHpAj3h.js +0 -83
@@ -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
- app.use(express.json());
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 (use these URLs when posting images, e.g. Facebook photo post): ${urls.join(', ')}]` : userMessageText;
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 (use these URLs when posting images, e.g. Facebook photo post): ${streamUrls.join(', ')}]` : streamMsg;
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/file, get a URL the agent can use (e.g. for Facebook photo posts). */
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
- app.post('/api/upload', express.json({ limit: '10mb' }), (req, res) => {
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 > 8 * 1024 * 1024) {
1834
- res.status(400).json({ error: 'File too large (max 8MB)' });
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