@vellumai/vellum-gateway 0.5.1 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -11,6 +11,7 @@ describe("config: hardcoded defaults", () => {
11
11
  expect(config.maxWebhookPayloadBytes).toBe(1024 * 1024);
12
12
  expect(config.maxAttachmentBytes).toEqual({
13
13
  telegram: 20 * 1024 * 1024,
14
+ telegramOutbound: 50 * 1024 * 1024,
14
15
  slack: 100 * 1024 * 1024,
15
16
  whatsapp: 16 * 1024 * 1024,
16
17
  default: 100 * 1024 * 1024,
@@ -111,6 +111,9 @@ function regexToOpenApiPath(escaped: string): string | null {
111
111
  return `{param${paramIndex}}`;
112
112
  });
113
113
 
114
+ // Strip optional trailing slash (`/?`) — common in route regexes
115
+ path = path.replace(/\/\?$/, "");
116
+
114
117
  // If there are remaining regex constructs we can't convert, skip
115
118
  if (/[\\()\[\].*+?{}|^$]/.test(path.replace(/\{param\d+\}/g, ""))) {
116
119
  return null;
@@ -64,6 +64,11 @@ describe("/schema route", () => {
64
64
  expect(body.paths["/v1/integrations/telegram/config"]).toBeDefined();
65
65
  expect(body.paths["/v1/integrations/telegram/commands"]).toBeDefined();
66
66
  expect(body.paths["/v1/integrations/telegram/setup"]).toBeDefined();
67
+ expect(body.paths["/v1/oauth/apps"]).toBeDefined();
68
+ expect(body.paths["/v1/oauth/apps/{appId}"]).toBeDefined();
69
+ expect(body.paths["/v1/oauth/apps/{appId}/connections"]).toBeDefined();
70
+ expect(body.paths["/v1/oauth/connections/{connectionId}"]).toBeDefined();
71
+ expect(body.paths["/v1/oauth/apps/{appId}/connect"]).toBeDefined();
67
72
  expect(body.paths["/v1/contacts"]).toBeDefined();
68
73
  expect(body.paths["/v1/contacts/merge"]).toBeDefined();
69
74
  expect(body.paths["/v1/contact-channels/{contactChannelId}"]).toBeDefined();
@@ -131,6 +136,17 @@ describe("buildSchema()", () => {
131
136
  expect(schemaNames).toContain("TelegramDocument");
132
137
  expect(schemaNames).toContain("TelegramDeliverRequest");
133
138
  expect(schemaNames).toContain("RuntimeAttachmentMeta");
139
+
140
+ const oauthConnection = components.schemas.OAuthConnectionSummary as {
141
+ properties?: Record<string, unknown>;
142
+ };
143
+ expect(oauthConnection.properties?.granted_scopes).toEqual({
144
+ type: "array",
145
+ items: { type: "string" },
146
+ });
147
+ expect(oauthConnection.properties?.has_refresh_token).toEqual({
148
+ type: "boolean",
149
+ });
134
150
  });
135
151
 
136
152
  test("returns a JSON-serializable object", () => {
package/src/config.ts CHANGED
@@ -137,7 +137,8 @@ export function loadConfig(): GatewayConfig {
137
137
  gatewayInternalBaseUrl,
138
138
  logFile,
139
139
  maxAttachmentBytes: {
140
- telegram: 20 * 1024 * 1024, // Telegram Bot API getFile limit
140
+ telegram: 20 * 1024 * 1024, // Telegram Bot API getFile (download) limit
141
+ telegramOutbound: 50 * 1024 * 1024, // Telegram Bot API sendDocument (upload) limit
141
142
  slack: 100 * 1024 * 1024, // Slack standard plan
142
143
  whatsapp: 16 * 1024 * 1024, // WhatsApp Business API limit
143
144
  default: 100 * 1024 * 1024, // Fallback; capped by runtime MAX_UPLOAD_BYTES (100 MB)
@@ -25,6 +25,14 @@
25
25
  "description": "Show the Contacts tab in Settings for viewing and managing contacts",
26
26
  "defaultEnabled": true
27
27
  },
28
+ {
29
+ "id": "custom-inference-provider",
30
+ "scope": "macos",
31
+ "key": "custom_inference_provider_enabled",
32
+ "label": "Custom Inference Provider",
33
+ "description": "Allow selecting a specific LLM provider and model for inference in Your Own mode",
34
+ "defaultEnabled": false
35
+ },
28
36
  {
29
37
  "id": "email-channel",
30
38
  "scope": "assistant",
@@ -160,11 +160,17 @@ function matchRoute(
160
160
  if (route.method && route.method !== method) return null;
161
161
 
162
162
  if (typeof route.path === "string") {
163
- if (pathname !== route.path) return null;
163
+ // Normalize trailing slashes so "/v1/foo/" matches "/v1/foo"
164
+ const normalized =
165
+ pathname.length > 1 && pathname.endsWith("/")
166
+ ? pathname.slice(0, -1)
167
+ : pathname;
168
+ if (normalized !== route.path) return null;
164
169
  return { params: [] };
165
170
  }
166
171
 
167
- // Regex path
172
+ // Regex path — use original pathname since regex routes may
173
+ // explicitly include trailing slashes in their patterns.
168
174
  const match = pathname.match(route.path);
169
175
  if (!match) return null;
170
176
  return { params: match.slice(1) };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Gateway proxy endpoints for OAuth app and connection management routes.
3
+ *
4
+ * These routes remain available even when the broad runtime proxy is
5
+ * disabled, so skills and clients can use gateway URLs exclusively.
6
+ */
7
+
8
+ import { mintServiceToken } from "../../auth/token-exchange.js";
9
+ import type { GatewayConfig } from "../../config.js";
10
+ import { fetchImpl } from "../../fetch.js";
11
+ import { getLogger } from "../../logger.js";
12
+ import { stripHopByHop } from "../../util/strip-hop-by-hop.js";
13
+
14
+ const log = getLogger("oauth-apps-proxy");
15
+
16
+ export function createOAuthAppsProxyHandler(config: GatewayConfig) {
17
+ async function proxyToRuntime(
18
+ req: Request,
19
+ upstreamPath: string,
20
+ upstreamSearch: string,
21
+ ): Promise<Response> {
22
+ const start = performance.now();
23
+ const upstream = `${config.assistantRuntimeBaseUrl}${upstreamPath}${upstreamSearch}`;
24
+
25
+ const reqHeaders = stripHopByHop(new Headers(req.headers));
26
+ reqHeaders.delete("host");
27
+ reqHeaders.delete("authorization");
28
+
29
+ reqHeaders.set("authorization", `Bearer ${mintServiceToken()}`);
30
+
31
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
32
+ const bodyBuffer = hasBody ? await req.arrayBuffer() : null;
33
+ if (bodyBuffer !== null) {
34
+ reqHeaders.set("content-length", String(bodyBuffer.byteLength));
35
+ }
36
+
37
+ const controller = new AbortController();
38
+ const timeoutId = setTimeout(() => {
39
+ controller.abort(
40
+ new DOMException(
41
+ "The operation was aborted due to timeout",
42
+ "TimeoutError",
43
+ ),
44
+ );
45
+ }, config.runtimeTimeoutMs);
46
+
47
+ let response: Response;
48
+ try {
49
+ response = await fetchImpl(upstream, {
50
+ method: req.method,
51
+ headers: reqHeaders,
52
+ body: bodyBuffer,
53
+ signal: controller.signal,
54
+ });
55
+ clearTimeout(timeoutId);
56
+ } catch (err) {
57
+ clearTimeout(timeoutId);
58
+ const duration = Math.round(performance.now() - start);
59
+ if (err instanceof DOMException && err.name === "TimeoutError") {
60
+ log.error(
61
+ { path: upstreamPath, duration },
62
+ "OAuth apps proxy upstream timed out",
63
+ );
64
+ return Response.json({ error: "Gateway Timeout" }, { status: 504 });
65
+ }
66
+ log.error(
67
+ { err, path: upstreamPath, duration },
68
+ "OAuth apps proxy upstream connection failed",
69
+ );
70
+ return Response.json({ error: "Bad Gateway" }, { status: 502 });
71
+ }
72
+
73
+ const resHeaders = stripHopByHop(new Headers(response.headers));
74
+ const duration = Math.round(performance.now() - start);
75
+
76
+ if (response.status >= 400) {
77
+ const body = await response.text();
78
+ log.warn(
79
+ { path: upstreamPath, status: response.status, duration },
80
+ "OAuth apps proxy upstream error",
81
+ );
82
+ return new Response(body, {
83
+ status: response.status,
84
+ headers: resHeaders,
85
+ });
86
+ }
87
+
88
+ log.info(
89
+ { path: upstreamPath, status: response.status, duration },
90
+ "OAuth apps proxy completed",
91
+ );
92
+ return new Response(response.body, {
93
+ status: response.status,
94
+ headers: resHeaders,
95
+ });
96
+ }
97
+
98
+ return {
99
+ async handleListApps(req: Request): Promise<Response> {
100
+ return proxyToRuntime(req, "/v1/oauth/apps", new URL(req.url).search);
101
+ },
102
+
103
+ async handleCreateApp(req: Request): Promise<Response> {
104
+ return proxyToRuntime(req, "/v1/oauth/apps", "");
105
+ },
106
+
107
+ async handleDeleteApp(req: Request, appId: string): Promise<Response> {
108
+ return proxyToRuntime(req, `/v1/oauth/apps/${appId}`, "");
109
+ },
110
+
111
+ async handleListConnections(
112
+ req: Request,
113
+ appId: string,
114
+ ): Promise<Response> {
115
+ return proxyToRuntime(req, `/v1/oauth/apps/${appId}/connections`, "");
116
+ },
117
+
118
+ async handleDeleteConnection(
119
+ req: Request,
120
+ connectionId: string,
121
+ ): Promise<Response> {
122
+ return proxyToRuntime(req, `/v1/oauth/connections/${connectionId}`, "");
123
+ },
124
+
125
+ async handleConnect(req: Request, appId: string): Promise<Response> {
126
+ return proxyToRuntime(req, `/v1/oauth/apps/${appId}/connect`, "");
127
+ },
128
+ };
129
+ }
package/src/index.ts CHANGED
@@ -50,6 +50,7 @@ import { createTelegramControlPlaneProxyHandler } from "./http/routes/telegram-c
50
50
  import { createContactsControlPlaneProxyHandler } from "./http/routes/contacts-control-plane-proxy.js";
51
51
  import { createTwilioControlPlaneProxyHandler } from "./http/routes/twilio-control-plane-proxy.js";
52
52
  import { createSlackControlPlaneProxyHandler } from "./http/routes/slack-control-plane-proxy.js";
53
+ import { createOAuthAppsProxyHandler } from "./http/routes/oauth-apps-proxy.js";
53
54
  import { createChannelReadinessProxyHandler } from "./http/routes/channel-readiness-proxy.js";
54
55
  import { createRuntimeHealthProxyHandler } from "./http/routes/runtime-health-proxy.js";
55
56
  import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
@@ -266,6 +267,7 @@ async function main() {
266
267
  createContactsControlPlaneProxyHandler(config);
267
268
  const twilioControlPlaneProxy = createTwilioControlPlaneProxyHandler(config);
268
269
  const slackControlPlaneProxy = createSlackControlPlaneProxyHandler(config);
270
+ const oauthAppsProxy = createOAuthAppsProxyHandler(config);
269
271
  const channelReadinessProxy = createChannelReadinessProxyHandler(config);
270
272
  const runtimeHealthProxy = createRuntimeHealthProxyHandler(config);
271
273
  const brainGraphProxy = createBrainGraphProxyHandler(config);
@@ -649,6 +651,46 @@ async function main() {
649
651
  handler: (req) => slackControlPlaneProxy.handleShareToSlack(req),
650
652
  },
651
653
 
654
+ // ── OAuth apps ──
655
+ {
656
+ path: "/v1/oauth/apps",
657
+ method: "GET",
658
+ auth: "edge",
659
+ handler: (req) => oauthAppsProxy.handleListApps(req),
660
+ },
661
+ {
662
+ path: "/v1/oauth/apps",
663
+ method: "POST",
664
+ auth: "edge",
665
+ handler: (req) => oauthAppsProxy.handleCreateApp(req),
666
+ },
667
+ {
668
+ path: /^\/v1\/oauth\/apps\/([^/]+)\/?$/,
669
+ method: "DELETE",
670
+ auth: "edge",
671
+ handler: (req, params) => oauthAppsProxy.handleDeleteApp(req, params[0]),
672
+ },
673
+ {
674
+ path: /^\/v1\/oauth\/apps\/([^/]+)\/connections\/?$/,
675
+ method: "GET",
676
+ auth: "edge",
677
+ handler: (req, params) =>
678
+ oauthAppsProxy.handleListConnections(req, params[0]),
679
+ },
680
+ {
681
+ path: /^\/v1\/oauth\/connections\/([^/]+)\/?$/,
682
+ method: "DELETE",
683
+ auth: "edge",
684
+ handler: (req, params) =>
685
+ oauthAppsProxy.handleDeleteConnection(req, params[0]),
686
+ },
687
+ {
688
+ path: /^\/v1\/oauth\/apps\/([^/]+)\/connect\/?$/,
689
+ method: "POST",
690
+ auth: "edge",
691
+ handler: (req, params) => oauthAppsProxy.handleConnect(req, params[0]),
692
+ },
693
+
652
694
  // ── Channel readiness ──
653
695
  {
654
696
  path: "/v1/channels/readiness",
@@ -310,47 +310,61 @@ export type UploadAttachmentResponse = {
310
310
  id: string;
311
311
  };
312
312
 
313
- export async function downloadAttachmentContent(
313
+ /**
314
+ * Internal helper that fetches raw attachment content without interacting
315
+ * with the circuit breaker. Used by downloadAttachment's hydration path
316
+ * which already owns the breaker lifecycle for the compound operation.
317
+ */
318
+ async function fetchAttachmentContentRaw(
314
319
  config: GatewayConfig,
315
320
  attachmentId: string,
316
321
  ): Promise<Buffer> {
317
- cbBeforeRequest();
318
-
319
- const url = `${config.assistantRuntimeBaseUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/content`;
322
+ const url = `${
323
+ config.assistantRuntimeBaseUrl
324
+ }/v1/attachments/${encodeURIComponent(attachmentId)}/content`;
320
325
 
321
- let response: Response;
322
- try {
323
- response = await fetchImpl(url, {
324
- method: "GET",
325
- headers: runtimeServiceHeaders(config),
326
- signal: AbortSignal.timeout(config.runtimeTimeoutMs),
327
- });
328
- } catch (err) {
329
- cbOnFailure();
330
- throw err;
331
- }
326
+ const response = await fetchImpl(url, {
327
+ method: "GET",
328
+ headers: runtimeServiceHeaders(config),
329
+ signal: AbortSignal.timeout(config.runtimeTimeoutMs),
330
+ });
332
331
 
333
332
  if (!response.ok) {
334
333
  const body = await response.text();
335
- if (response.status >= 500) cbOnFailure();
336
- else cbOnSuccess();
337
334
  throw new Error(
338
335
  `Attachment content download failed (${response.status}): ${body}`,
339
336
  );
340
337
  }
341
338
 
342
- cbOnSuccess();
343
339
  const arrayBuffer = await response.arrayBuffer();
344
340
  return Buffer.from(arrayBuffer);
345
341
  }
346
342
 
343
+ export async function downloadAttachmentContent(
344
+ config: GatewayConfig,
345
+ attachmentId: string,
346
+ ): Promise<Buffer> {
347
+ cbBeforeRequest();
348
+
349
+ try {
350
+ const buffer = await fetchAttachmentContentRaw(config, attachmentId);
351
+ cbOnSuccess();
352
+ return buffer;
353
+ } catch (err) {
354
+ cbOnFailure();
355
+ throw err;
356
+ }
357
+ }
358
+
347
359
  export async function downloadAttachment(
348
360
  config: GatewayConfig,
349
361
  attachmentId: string,
350
362
  ): Promise<HydratedAttachmentPayload> {
351
363
  cbBeforeRequest();
352
364
 
353
- const url = `${config.assistantRuntimeBaseUrl}/v1/attachments/${encodeURIComponent(attachmentId)}`;
365
+ const url = `${
366
+ config.assistantRuntimeBaseUrl
367
+ }/v1/attachments/${encodeURIComponent(attachmentId)}`;
354
368
 
355
369
  let response: Response;
356
370
  try {
@@ -375,15 +389,24 @@ export async function downloadAttachment(
375
389
 
376
390
  // Transparently hydrate file-backed attachments: fetch the binary content
377
391
  // from the dedicated /content endpoint and inline it as base64.
378
- // Note: we defer cbOnSuccess() until after hydration so that a recurring
379
- // pattern of metadata-200 + content-5xx correctly accumulates breaker
380
- // failures instead of resetting the counter on every metadata success.
381
- if (payload.fileBacked && !payload.data) {
382
- const contentBuffer = await downloadAttachmentContent(config, attachmentId);
383
- payload.data = contentBuffer.toString("base64");
392
+ // We use the raw helper (no nested circuit breaker) so the compound
393
+ // metadata+content operation is treated as a single breaker unit.
394
+ // If content fetch fails, cbOnFailure() fires for the whole operation.
395
+ if (payload.fileBacked && payload.data == null) {
396
+ try {
397
+ const contentBuffer = await fetchAttachmentContentRaw(
398
+ config,
399
+ attachmentId,
400
+ );
401
+ payload.data = contentBuffer.toString("base64");
402
+ } catch (err) {
403
+ cbOnFailure();
404
+ throw err;
405
+ }
384
406
  }
385
407
 
386
- if (!payload.data) {
408
+ // Use == null to allow empty string (valid base64 for zero-byte attachments)
409
+ if (payload.data == null) {
387
410
  throw new Error(`Attachment ${attachmentId} has no data after hydration`);
388
411
  }
389
412
 
package/src/schema.ts CHANGED
@@ -1615,6 +1615,226 @@ export function buildSchema(): Record<string, unknown> {
1615
1615
  },
1616
1616
  },
1617
1617
  },
1618
+ "/v1/oauth/apps": {
1619
+ get: {
1620
+ summary: "List OAuth apps",
1621
+ description:
1622
+ "Authenticated gateway endpoint that lists configured OAuth apps for a provider by proxying to the assistant runtime.",
1623
+ operationId: "oauthAppsList",
1624
+ parameters: [
1625
+ {
1626
+ name: "provider_key",
1627
+ in: "query",
1628
+ required: true,
1629
+ schema: { type: "string" },
1630
+ description:
1631
+ "OAuth provider key to filter by, for example `integration:google`.",
1632
+ },
1633
+ ],
1634
+ security: [{ BearerAuth: [] }],
1635
+ responses: {
1636
+ "200": {
1637
+ description: "OAuth apps returned",
1638
+ content: {
1639
+ "application/json": {
1640
+ schema: {
1641
+ $ref: "#/components/schemas/OAuthAppListResponse",
1642
+ },
1643
+ },
1644
+ },
1645
+ },
1646
+ "400": { description: "Missing or invalid provider_key query parameter" },
1647
+ "401": {
1648
+ description: "Unauthorized — missing or invalid bearer token",
1649
+ },
1650
+ "503": { description: "Bearer token not configured" },
1651
+ "502": { description: "Failed to reach assistant runtime" },
1652
+ "504": { description: "Assistant runtime request timed out" },
1653
+ },
1654
+ },
1655
+ post: {
1656
+ summary: "Create OAuth app",
1657
+ description:
1658
+ "Authenticated gateway endpoint that creates or updates a user-managed OAuth app by proxying to the assistant runtime.",
1659
+ operationId: "oauthAppsCreate",
1660
+ security: [{ BearerAuth: [] }],
1661
+ requestBody: {
1662
+ required: true,
1663
+ content: {
1664
+ "application/json": {
1665
+ schema: { $ref: "#/components/schemas/OAuthAppCreateRequest" },
1666
+ },
1667
+ },
1668
+ },
1669
+ responses: {
1670
+ "201": {
1671
+ description: "OAuth app created",
1672
+ content: {
1673
+ "application/json": {
1674
+ schema: {
1675
+ $ref: "#/components/schemas/OAuthAppCreateResponse",
1676
+ },
1677
+ },
1678
+ },
1679
+ },
1680
+ "400": { description: "Invalid request payload" },
1681
+ "401": {
1682
+ description: "Unauthorized — missing or invalid bearer token",
1683
+ },
1684
+ "404": { description: "OAuth provider not found" },
1685
+ "503": { description: "Bearer token not configured" },
1686
+ "502": { description: "Failed to reach assistant runtime" },
1687
+ "504": { description: "Assistant runtime request timed out" },
1688
+ },
1689
+ },
1690
+ },
1691
+ "/v1/oauth/apps/{appId}": {
1692
+ delete: {
1693
+ summary: "Delete OAuth app",
1694
+ description:
1695
+ "Authenticated gateway endpoint that deletes a user-managed OAuth app and disconnects its linked accounts by proxying to the assistant runtime.",
1696
+ operationId: "oauthAppsDelete",
1697
+ parameters: [
1698
+ {
1699
+ name: "appId",
1700
+ in: "path",
1701
+ required: true,
1702
+ schema: { type: "string" },
1703
+ },
1704
+ ],
1705
+ security: [{ BearerAuth: [] }],
1706
+ responses: {
1707
+ "200": {
1708
+ description: "OAuth app deleted",
1709
+ content: {
1710
+ "application/json": {
1711
+ schema: { $ref: "#/components/schemas/OkResponse" },
1712
+ },
1713
+ },
1714
+ },
1715
+ "401": {
1716
+ description: "Unauthorized — missing or invalid bearer token",
1717
+ },
1718
+ "404": { description: "OAuth app not found" },
1719
+ "503": { description: "Bearer token not configured" },
1720
+ "502": { description: "Failed to reach assistant runtime" },
1721
+ "504": { description: "Assistant runtime request timed out" },
1722
+ },
1723
+ },
1724
+ },
1725
+ "/v1/oauth/apps/{appId}/connections": {
1726
+ get: {
1727
+ summary: "List OAuth app connections",
1728
+ description:
1729
+ "Authenticated gateway endpoint that lists linked accounts for a specific OAuth app by proxying to the assistant runtime.",
1730
+ operationId: "oauthAppConnectionsList",
1731
+ parameters: [
1732
+ {
1733
+ name: "appId",
1734
+ in: "path",
1735
+ required: true,
1736
+ schema: { type: "string" },
1737
+ },
1738
+ ],
1739
+ security: [{ BearerAuth: [] }],
1740
+ responses: {
1741
+ "200": {
1742
+ description: "OAuth app connections returned",
1743
+ content: {
1744
+ "application/json": {
1745
+ schema: {
1746
+ $ref: "#/components/schemas/OAuthConnectionListResponse",
1747
+ },
1748
+ },
1749
+ },
1750
+ },
1751
+ "401": {
1752
+ description: "Unauthorized — missing or invalid bearer token",
1753
+ },
1754
+ "404": { description: "OAuth app not found" },
1755
+ "503": { description: "Bearer token not configured" },
1756
+ "502": { description: "Failed to reach assistant runtime" },
1757
+ "504": { description: "Assistant runtime request timed out" },
1758
+ },
1759
+ },
1760
+ },
1761
+ "/v1/oauth/connections/{connectionId}": {
1762
+ delete: {
1763
+ summary: "Delete OAuth connection",
1764
+ description:
1765
+ "Authenticated gateway endpoint that disconnects a linked OAuth account by proxying to the assistant runtime.",
1766
+ operationId: "oauthConnectionDelete",
1767
+ parameters: [
1768
+ {
1769
+ name: "connectionId",
1770
+ in: "path",
1771
+ required: true,
1772
+ schema: { type: "string" },
1773
+ },
1774
+ ],
1775
+ security: [{ BearerAuth: [] }],
1776
+ responses: {
1777
+ "200": {
1778
+ description: "OAuth connection deleted",
1779
+ content: {
1780
+ "application/json": {
1781
+ schema: { $ref: "#/components/schemas/OkResponse" },
1782
+ },
1783
+ },
1784
+ },
1785
+ "401": {
1786
+ description: "Unauthorized — missing or invalid bearer token",
1787
+ },
1788
+ "404": { description: "OAuth connection not found" },
1789
+ "503": { description: "Bearer token not configured" },
1790
+ "502": { description: "Failed to reach assistant runtime" },
1791
+ "504": { description: "Assistant runtime request timed out" },
1792
+ },
1793
+ },
1794
+ },
1795
+ "/v1/oauth/apps/{appId}/connect": {
1796
+ post: {
1797
+ summary: "Start OAuth app connect flow",
1798
+ description:
1799
+ "Authenticated gateway endpoint that starts an OAuth authorization flow for a specific app by proxying to the assistant runtime.",
1800
+ operationId: "oauthAppConnect",
1801
+ parameters: [
1802
+ {
1803
+ name: "appId",
1804
+ in: "path",
1805
+ required: true,
1806
+ schema: { type: "string" },
1807
+ },
1808
+ ],
1809
+ security: [{ BearerAuth: [] }],
1810
+ requestBody: {
1811
+ required: false,
1812
+ content: {
1813
+ "application/json": {
1814
+ schema: { $ref: "#/components/schemas/OAuthConnectRequest" },
1815
+ },
1816
+ },
1817
+ },
1818
+ responses: {
1819
+ "200": {
1820
+ description: "OAuth connect flow started",
1821
+ content: {
1822
+ "application/json": {
1823
+ schema: { $ref: "#/components/schemas/OAuthConnectResponse" },
1824
+ },
1825
+ },
1826
+ },
1827
+ "401": {
1828
+ description: "Unauthorized — missing or invalid bearer token",
1829
+ },
1830
+ "404": { description: "OAuth app not found" },
1831
+ "500": { description: "Failed to start OAuth flow" },
1832
+ "503": { description: "Bearer token not configured" },
1833
+ "502": { description: "Failed to reach assistant runtime" },
1834
+ "504": { description: "Assistant runtime request timed out" },
1835
+ },
1836
+ },
1837
+ },
1618
1838
  "/v1/channels/readiness": {
1619
1839
  get: {
1620
1840
  summary: "Get channel readiness",
@@ -2263,6 +2483,117 @@ export function buildSchema(): Record<string, unknown> {
2263
2483
  error: { type: "string" },
2264
2484
  },
2265
2485
  },
2486
+ OkResponse: {
2487
+ type: "object",
2488
+ required: ["ok"],
2489
+ properties: {
2490
+ ok: { type: "boolean" },
2491
+ },
2492
+ },
2493
+ OAuthAppSummary: {
2494
+ type: "object",
2495
+ required: [
2496
+ "id",
2497
+ "provider_key",
2498
+ "client_id",
2499
+ "created_at",
2500
+ "updated_at",
2501
+ ],
2502
+ properties: {
2503
+ id: { type: "string" },
2504
+ provider_key: { type: "string" },
2505
+ client_id: { type: "string" },
2506
+ created_at: { type: "integer" },
2507
+ updated_at: { type: "integer" },
2508
+ },
2509
+ },
2510
+ OAuthAppListResponse: {
2511
+ type: "object",
2512
+ required: ["apps"],
2513
+ properties: {
2514
+ apps: {
2515
+ type: "array",
2516
+ items: { $ref: "#/components/schemas/OAuthAppSummary" },
2517
+ },
2518
+ },
2519
+ },
2520
+ OAuthAppCreateRequest: {
2521
+ type: "object",
2522
+ required: ["provider_key", "client_id", "client_secret"],
2523
+ properties: {
2524
+ provider_key: { type: "string" },
2525
+ client_id: { type: "string" },
2526
+ client_secret: { type: "string" },
2527
+ },
2528
+ },
2529
+ OAuthAppCreateResponse: {
2530
+ type: "object",
2531
+ required: ["app"],
2532
+ properties: {
2533
+ app: { $ref: "#/components/schemas/OAuthAppSummary" },
2534
+ },
2535
+ },
2536
+ OAuthConnectionSummary: {
2537
+ type: "object",
2538
+ required: [
2539
+ "id",
2540
+ "provider_key",
2541
+ "account_info",
2542
+ "granted_scopes",
2543
+ "status",
2544
+ "has_refresh_token",
2545
+ "expires_at",
2546
+ "created_at",
2547
+ "updated_at",
2548
+ ],
2549
+ properties: {
2550
+ id: { type: "string" },
2551
+ provider_key: { type: "string" },
2552
+ account_info: { type: ["string", "null"] },
2553
+ granted_scopes: {
2554
+ type: "array",
2555
+ items: { type: "string" },
2556
+ },
2557
+ status: { type: "string" },
2558
+ has_refresh_token: { type: "boolean" },
2559
+ expires_at: { type: ["integer", "null"] },
2560
+ created_at: { type: "integer" },
2561
+ updated_at: { type: "integer" },
2562
+ },
2563
+ },
2564
+ OAuthConnectionListResponse: {
2565
+ type: "object",
2566
+ required: ["connections"],
2567
+ properties: {
2568
+ connections: {
2569
+ type: "array",
2570
+ items: { $ref: "#/components/schemas/OAuthConnectionSummary" },
2571
+ },
2572
+ },
2573
+ },
2574
+ OAuthConnectRequest: {
2575
+ type: "object",
2576
+ properties: {
2577
+ scopes: {
2578
+ type: "array",
2579
+ items: { type: "string" },
2580
+ },
2581
+ },
2582
+ },
2583
+ OAuthConnectDeferredResponse: {
2584
+ type: "object",
2585
+ required: ["auth_url", "state"],
2586
+ properties: {
2587
+ auth_url: { type: "string" },
2588
+ state: { type: "string" },
2589
+ },
2590
+ },
2591
+ OAuthConnectResponse: {
2592
+ oneOf: [
2593
+ { $ref: "#/components/schemas/OAuthConnectDeferredResponse" },
2594
+ { $ref: "#/components/schemas/OkResponse" },
2595
+ ],
2596
+ },
2266
2597
  TelegramOk: {
2267
2598
  type: "object",
2268
2599
  required: ["ok"],
@@ -82,10 +82,12 @@ export async function sendTelegramAttachments(
82
82
 
83
83
  for (const meta of attachments) {
84
84
  // When size is known upfront, skip oversized attachments before downloading.
85
+ // Use the outbound limit (sendDocument supports 50 MB) rather than the
86
+ // inbound getFile limit (20 MB).
85
87
  if (
86
88
  meta.sizeBytes !== undefined &&
87
89
  meta.sizeBytes >
88
- (config.maxAttachmentBytes.telegram ??
90
+ (config.maxAttachmentBytes.telegramOutbound ??
89
91
  config.maxAttachmentBytes.default)
90
92
  ) {
91
93
  log.warn(
@@ -111,7 +113,7 @@ export async function sendTelegramAttachments(
111
113
  // Check size after hydration for ID-only payloads where size was unknown.
112
114
  if (
113
115
  sizeBytes >
114
- (config.maxAttachmentBytes.telegram ??
116
+ (config.maxAttachmentBytes.telegramOutbound ??
115
117
  config.maxAttachmentBytes.default)
116
118
  ) {
117
119
  log.warn(