@vellumai/vellum-gateway 0.5.11 → 0.5.13

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.11",
3
+ "version": "0.5.13",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,224 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const TEST_SERVICE_TOKEN = "test-ces-service-token";
9
+
10
+ const testDir = join(tmpdir(), `gw-managed-${Date.now()}-${Math.random()}`);
11
+
12
+ function metadataRecord(
13
+ credentialId: string,
14
+ service: string,
15
+ field: string,
16
+ ): Record<string, unknown> {
17
+ return {
18
+ credentialId,
19
+ service,
20
+ field,
21
+ allowedTools: [],
22
+ allowedDomains: [],
23
+ createdAt: Date.now(),
24
+ updatedAt: Date.now(),
25
+ };
26
+ }
27
+
28
+ function writeCredentialMetadata(
29
+ credentials: Record<string, unknown>[] = [
30
+ metadataRecord("test-bt", "telegram", "bot_token"),
31
+ metadataRecord("test-ws", "telegram", "webhook_secret"),
32
+ ],
33
+ ): void {
34
+ const dir = join(testDir, ".vellum", "workspace", "data", "credentials");
35
+ mkdirSync(dir, { recursive: true });
36
+ const metadataPath = join(dir, "metadata.json");
37
+ const tmpPath = join(dir, `.tmp-${Date.now()}-metadata.json`);
38
+ writeFileSync(
39
+ tmpPath,
40
+ JSON.stringify({
41
+ version: 2,
42
+ credentials,
43
+ }),
44
+ );
45
+ renameSync(tmpPath, metadataPath);
46
+ }
47
+
48
+ const __dirname = dirname(fileURLToPath(import.meta.url));
49
+ const gatewayRoot = join(__dirname, "..", "..");
50
+ const gatewayEntry = join(gatewayRoot, "src", "index.ts");
51
+
52
+ let gatewayProc: ChildProcess | null = null;
53
+ let gatewayPort = 0;
54
+ let cesPort = 0;
55
+ let cesServer: ReturnType<typeof Bun.serve> | null = null;
56
+
57
+ function assignPorts(): void {
58
+ if (gatewayPort !== 0 && cesPort !== 0) return;
59
+ gatewayPort = 49152 + Math.floor(Math.random() * 8_192);
60
+ cesPort = gatewayPort + 1;
61
+ }
62
+
63
+ async function startGateway(): Promise<void> {
64
+ assignPorts();
65
+
66
+ gatewayProc = spawn("bun", ["run", gatewayEntry], {
67
+ env: {
68
+ ...process.env,
69
+ BASE_DATA_DIR: testDir,
70
+ GATEWAY_PORT: String(gatewayPort),
71
+ CES_CREDENTIAL_URL: `http://127.0.0.1:${cesPort}`,
72
+ CES_SERVICE_TOKEN: TEST_SERVICE_TOKEN,
73
+ TELEGRAM_BOT_TOKEN: "",
74
+ TELEGRAM_WEBHOOK_SECRET: "",
75
+ },
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ });
78
+
79
+ const deadline = Date.now() + 5_000;
80
+ while (Date.now() < deadline) {
81
+ try {
82
+ const res = await fetch(`http://localhost:${gatewayPort}/healthz`);
83
+ if (res.ok) return;
84
+ } catch {
85
+ // Gateway not ready yet.
86
+ }
87
+ await new Promise((resolve) => setTimeout(resolve, 100));
88
+ }
89
+ throw new Error("Gateway failed to start within 5 seconds");
90
+ }
91
+
92
+ function startFakeCes(opts: {
93
+ accounts?: string[];
94
+ credentials?: Record<string, string>;
95
+ resolveValue?: (account: string) => string | undefined;
96
+ }): void {
97
+ assignPorts();
98
+ const accounts = opts.accounts ?? Object.keys(opts.credentials ?? {});
99
+ const credentials = opts.credentials ?? {};
100
+ cesServer = Bun.serve({
101
+ port: cesPort,
102
+ fetch(req) {
103
+ const authHeader = req.headers.get("authorization");
104
+ if (authHeader !== `Bearer ${TEST_SERVICE_TOKEN}`) {
105
+ return Response.json(
106
+ { error: "Invalid service token" },
107
+ { status: 403 },
108
+ );
109
+ }
110
+
111
+ const url = new URL(req.url);
112
+ if (req.method === "GET" && url.pathname === "/v1/credentials") {
113
+ return Response.json({ accounts });
114
+ }
115
+
116
+ if (req.method === "GET" && url.pathname.startsWith("/v1/credentials/")) {
117
+ const account = decodeURIComponent(
118
+ url.pathname.slice("/v1/credentials/".length),
119
+ );
120
+ const value = opts.resolveValue?.(account) ?? credentials[account];
121
+ if (!value) {
122
+ return Response.json(
123
+ { error: "Credential not found", account },
124
+ { status: 404 },
125
+ );
126
+ }
127
+ return Response.json({ account, value });
128
+ }
129
+
130
+ return new Response("Not Found", { status: 404 });
131
+ },
132
+ });
133
+ }
134
+
135
+ afterEach(() => {
136
+ cesServer?.stop(true);
137
+ cesServer = null;
138
+ gatewayPort = 0;
139
+ cesPort = 0;
140
+
141
+ if (gatewayProc) {
142
+ gatewayProc.kill();
143
+ gatewayProc = null;
144
+ }
145
+
146
+ rmSync(testDir, { recursive: true, force: true });
147
+ });
148
+
149
+ describe("gateway managed credential bootstrap retry", () => {
150
+ test("reloads Telegram credentials after CES becomes reachable without a metadata rewrite", async () => {
151
+ mkdirSync(testDir, { recursive: true });
152
+ writeCredentialMetadata();
153
+
154
+ await startGateway();
155
+
156
+ const base = `http://localhost:${gatewayPort}`;
157
+ const before = await fetch(`${base}/webhooks/telegram`, { method: "POST" });
158
+ expect(before.status).toBe(503);
159
+
160
+ startFakeCes({
161
+ credentials: {
162
+ "credential/telegram/bot_token": "fake-bot-token:ABC123",
163
+ "credential/telegram/webhook_secret": "fake-webhook-secret",
164
+ },
165
+ });
166
+
167
+ const deadline = Date.now() + 5_000;
168
+ let status = before.status;
169
+ while (Date.now() < deadline) {
170
+ const resp = await fetch(`${base}/webhooks/telegram`, {
171
+ method: "POST",
172
+ });
173
+ status = resp.status;
174
+ if (status === 401) break;
175
+ await new Promise((resolve) => setTimeout(resolve, 200));
176
+ }
177
+
178
+ expect(status).toBe(401);
179
+ }, 15_000);
180
+
181
+ test("keeps retrying until configured credential reads succeed after CES list is already available", async () => {
182
+ mkdirSync(testDir, { recursive: true });
183
+ writeCredentialMetadata();
184
+
185
+ let readsReady = false;
186
+ startFakeCes({
187
+ accounts: [
188
+ "credential/telegram/bot_token",
189
+ "credential/telegram/webhook_secret",
190
+ ],
191
+ resolveValue(account) {
192
+ if (!readsReady) return undefined;
193
+ if (account === "credential/telegram/bot_token") {
194
+ return "fake-bot-token:ABC123";
195
+ }
196
+ if (account === "credential/telegram/webhook_secret") {
197
+ return "fake-webhook-secret";
198
+ }
199
+ return undefined;
200
+ },
201
+ });
202
+
203
+ await startGateway();
204
+
205
+ const base = `http://localhost:${gatewayPort}`;
206
+ const before = await fetch(`${base}/webhooks/telegram`, { method: "POST" });
207
+ expect(before.status).toBe(503);
208
+
209
+ readsReady = true;
210
+
211
+ const deadline = Date.now() + 5_000;
212
+ let status = before.status;
213
+ while (Date.now() < deadline) {
214
+ const resp = await fetch(`${base}/webhooks/telegram`, {
215
+ method: "POST",
216
+ });
217
+ status = resp.status;
218
+ if (status === 401) break;
219
+ await new Promise((resolve) => setTimeout(resolve, 200));
220
+ }
221
+
222
+ expect(status).toBe(401);
223
+ }, 15_000);
224
+ });
@@ -9,7 +9,13 @@
9
9
  * causing later credential changes to be missed until restart.
10
10
  */
11
11
 
12
- import { mkdirSync, watch, type FSWatcher } from "node:fs";
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ watch,
17
+ type FSWatcher,
18
+ } from "node:fs";
13
19
  import { dirname, join } from "node:path";
14
20
  import { getLogger } from "./logger.js";
15
21
  import {
@@ -22,6 +28,8 @@ import {
22
28
  const log = getLogger("credential-watcher");
23
29
 
24
30
  const DEBOUNCE_MS = 500;
31
+ const MANAGED_BOOTSTRAP_POLL_MS = 1_000;
32
+ const MANAGED_BOOTSTRAP_TIMEOUT_MS = 1_000;
25
33
 
26
34
  export type CredentialChangeEvent = {
27
35
  /** Map from service name to resolved credentials (null if unavailable) */
@@ -35,6 +43,10 @@ export type CredentialChangeCallback = (event: CredentialChangeEvent) => void;
35
43
  export class CredentialWatcher {
36
44
  private watchers: FSWatcher[] = [];
37
45
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
46
+ private managedBootstrapTimer: ReturnType<typeof setInterval> | null = null;
47
+ private managedBootstrapPollInFlight = false;
48
+ private lastConfiguredServices = new Set<string>();
49
+ private lastReadyServices = new Set<string>();
38
50
  private lastSerialized: Map<string, string> = new Map();
39
51
  private polling = false;
40
52
  private pendingPoll = false;
@@ -70,6 +82,8 @@ export class CredentialWatcher {
70
82
  // but the encrypted ciphertext will (new IV). Force a full reload so
71
83
  // channel listeners restart even when the plaintext values match.
72
84
  this.startWatcher(protectedDir, "keys.enc", { forceChanged: true });
85
+
86
+ this.startManagedBootstrapRetry();
73
87
  }
74
88
 
75
89
  private startWatcher(
@@ -101,6 +115,11 @@ export class CredentialWatcher {
101
115
  clearTimeout(this.debounceTimer);
102
116
  this.debounceTimer = null;
103
117
  }
118
+ if (this.managedBootstrapTimer) {
119
+ clearInterval(this.managedBootstrapTimer);
120
+ this.managedBootstrapTimer = null;
121
+ }
122
+ this.managedBootstrapPollInFlight = false;
104
123
  this.pendingPoll = false;
105
124
  for (const watcher of this.watchers) {
106
125
  watcher.close();
@@ -108,6 +127,63 @@ export class CredentialWatcher {
108
127
  this.watchers = [];
109
128
  }
110
129
 
130
+ private startManagedBootstrapRetry(): void {
131
+ const baseUrl = process.env.CES_CREDENTIAL_URL?.trim();
132
+ const serviceToken = process.env.CES_SERVICE_TOKEN?.trim();
133
+ if (!baseUrl || !serviceToken) return;
134
+
135
+ const poll = (): void => {
136
+ void this.pollManagedBootstrap(baseUrl, serviceToken);
137
+ };
138
+
139
+ this.managedBootstrapTimer = setInterval(poll, MANAGED_BOOTSTRAP_POLL_MS);
140
+ this.managedBootstrapTimer.unref?.();
141
+ poll();
142
+ }
143
+
144
+ private async pollManagedBootstrap(
145
+ baseUrl: string,
146
+ serviceToken: string,
147
+ ): Promise<void> {
148
+ if (this.managedBootstrapPollInFlight) return;
149
+ this.managedBootstrapPollInFlight = true;
150
+ try {
151
+ const resp = await fetch(`${baseUrl}/v1/credentials`, {
152
+ method: "GET",
153
+ headers: {
154
+ Authorization: `Bearer ${serviceToken}`,
155
+ Accept: "application/json",
156
+ },
157
+ signal: AbortSignal.timeout(MANAGED_BOOTSTRAP_TIMEOUT_MS),
158
+ });
159
+ if (resp.status === 401 || resp.status === 403 || resp.status === 404) {
160
+ if (this.managedBootstrapTimer) {
161
+ clearInterval(this.managedBootstrapTimer);
162
+ this.managedBootstrapTimer = null;
163
+ }
164
+ log.warn(
165
+ { status: resp.status },
166
+ "Stopping managed credential bootstrap retry due to non-retryable CES response",
167
+ );
168
+ return;
169
+ }
170
+ if (!resp.ok) {
171
+ return;
172
+ }
173
+
174
+ await this.pollOnce();
175
+
176
+ if (this.allConfiguredServicesReady() && this.managedBootstrapTimer) {
177
+ clearInterval(this.managedBootstrapTimer);
178
+ this.managedBootstrapTimer = null;
179
+ }
180
+ } catch {
181
+ // CES isn't reachable yet. Keep retrying until the sidecar is ready.
182
+ } finally {
183
+ this.managedBootstrapPollInFlight = false;
184
+ }
185
+ }
186
+
111
187
  /** Whether the next scheduled poll should treat all services as changed. */
112
188
  private pendingForceChanged = false;
113
189
 
@@ -135,9 +211,16 @@ export class CredentialWatcher {
135
211
  this.polling = true;
136
212
  try {
137
213
  const credentials = new Map<string, Record<string, string> | null>();
214
+ const configuredServices = this.loadConfiguredServices();
138
215
  for (const spec of ALL_CREDENTIAL_SPECS) {
139
216
  credentials.set(spec.service, await readServiceCredentials(spec));
140
217
  }
218
+ this.lastConfiguredServices = configuredServices;
219
+ this.lastReadyServices = new Set(
220
+ [...credentials.entries()]
221
+ .filter(([, creds]) => creds !== null)
222
+ .map(([service]) => service),
223
+ );
141
224
 
142
225
  const changedServices = new Set<string>();
143
226
  for (const [service, creds] of credentials) {
@@ -166,4 +249,42 @@ export class CredentialWatcher {
166
249
  }
167
250
  }
168
251
  }
252
+
253
+ private loadConfiguredServices(): Set<string> {
254
+ if (!existsSync(this.metadataPath)) return new Set();
255
+
256
+ try {
257
+ const raw = readFileSync(this.metadataPath, "utf-8");
258
+ const data = JSON.parse(raw) as {
259
+ credentials?: Array<{ service?: string; field?: string }>;
260
+ };
261
+ if (!Array.isArray(data.credentials)) return new Set();
262
+
263
+ const configured = new Set<string>();
264
+ for (const spec of ALL_CREDENTIAL_SPECS) {
265
+ const hasAllRequiredFields = spec.requiredFields.every((field) =>
266
+ data.credentials?.some(
267
+ (credential) =>
268
+ credential.service === spec.service && credential.field === field,
269
+ ),
270
+ );
271
+ if (hasAllRequiredFields) {
272
+ configured.add(spec.service);
273
+ }
274
+ }
275
+
276
+ return configured;
277
+ } catch {
278
+ return new Set();
279
+ }
280
+ }
281
+
282
+ private allConfiguredServicesReady(): boolean {
283
+ for (const service of this.lastConfiguredServices) {
284
+ if (!this.lastReadyServices.has(service)) {
285
+ return false;
286
+ }
287
+ }
288
+ return true;
289
+ }
169
290
  }
@@ -47,7 +47,7 @@
47
47
  "key": "app-builder-multifile",
48
48
  "label": "App Builder Multi-file",
49
49
  "description": "Enable multi-file TSX app creation with esbuild compilation instead of single-HTML apps",
50
- "defaultEnabled": false
50
+ "defaultEnabled": true
51
51
  },
52
52
  {
53
53
  "id": "mobile-pairing",
@@ -255,7 +255,7 @@
255
255
  "key": "managed-google-oauth",
256
256
  "label": "Managed Google OAuth",
257
257
  "description": "Show the Google OAuth service card in Models & Services settings",
258
- "defaultEnabled": false
258
+ "defaultEnabled": true
259
259
  },
260
260
  {
261
261
  "id": "settings-embedding-provider",
@@ -4,8 +4,11 @@ import { validateEdgeToken } from "../../auth/token-exchange.js";
4
4
  import { resolveScopeProfile } from "../../auth/scopes.js";
5
5
  import type { Scope } from "../../auth/types.js";
6
6
  import type { AuthRateLimiter } from "../../auth-rate-limiter.js";
7
+ import { getLogger } from "../../logger.js";
7
8
  import { isLoopbackPeer } from "../routes/browser-relay-websocket.js";
8
9
 
10
+ const log = getLogger("auth");
11
+
9
12
  type GetClientIp = () => string;
10
13
 
11
14
  /**
@@ -34,11 +37,19 @@ export function createAuthMiddleware(
34
37
  const token = extractBearerToken(req);
35
38
  if (!token) {
36
39
  authRateLimiter.recordFailure(getClientIp());
40
+ log.warn(
41
+ { path: new URL(req.url).pathname },
42
+ "Edge auth rejected: missing or malformed Authorization header",
43
+ );
37
44
  return Response.json({ error: "Unauthorized" }, { status: 401 });
38
45
  }
39
46
  const result = validateEdgeToken(token);
40
47
  if (!result.ok) {
41
48
  authRateLimiter.recordFailure(getClientIp());
49
+ log.warn(
50
+ { path: new URL(req.url).pathname, reason: result.reason },
51
+ "Edge auth rejected: token validation failed",
52
+ );
42
53
  return Response.json({ error: "Unauthorized" }, { status: 401 });
43
54
  }
44
55
  return null;
@@ -60,11 +71,19 @@ export function createAuthMiddleware(
60
71
  const token = extractBearerToken(req);
61
72
  if (!token) {
62
73
  authRateLimiter.recordFailure(getClientIp());
74
+ log.warn(
75
+ { path: new URL(req.url).pathname, scope },
76
+ "Scoped edge auth rejected: missing or malformed Authorization header",
77
+ );
63
78
  return Response.json({ error: "Unauthorized" }, { status: 401 });
64
79
  }
65
80
  const result = validateEdgeToken(token);
66
81
  if (!result.ok) {
67
82
  authRateLimiter.recordFailure(getClientIp());
83
+ log.warn(
84
+ { path: new URL(req.url).pathname, scope, reason: result.reason },
85
+ "Scoped edge auth rejected: token validation failed",
86
+ );
68
87
  return Response.json({ error: "Unauthorized" }, { status: 401 });
69
88
  }
70
89
  const scopes = resolveScopeProfile(result.claims.scope_profile);
@@ -1,4 +1,7 @@
1
1
  import { verifyToken } from "../../auth/token-service.js";
2
+ import { getLogger } from "../../logger.js";
3
+
4
+ const log = getLogger("deliver-auth");
2
5
 
3
6
  /**
4
7
  * Creates a fail-closed auth check for delivery routes.
@@ -21,12 +24,20 @@ export function checkDeliverAuth(
21
24
 
22
25
  const authHeader = req.headers.get("authorization");
23
26
  if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
27
+ log.warn(
28
+ { path: new URL(req.url).pathname },
29
+ "Deliver auth rejected: missing or malformed Authorization header",
30
+ );
24
31
  return Response.json({ error: "Unauthorized" }, { status: 401 });
25
32
  }
26
33
 
27
34
  const token = authHeader.slice(7);
28
35
  const result = verifyToken(token, "vellum-daemon");
29
36
  if (!result.ok) {
37
+ log.warn(
38
+ { path: new URL(req.url).pathname, reason: result.reason },
39
+ "Deliver auth rejected: token validation failed",
40
+ );
30
41
  return Response.json({ error: "Unauthorized" }, { status: 401 });
31
42
  }
32
43
 
@@ -44,12 +44,19 @@ export function createRuntimeProxyHandler(config: GatewayConfig) {
44
44
  if (config.runtimeProxyRequireAuth && req.method !== "OPTIONS") {
45
45
  const authHeader = req.headers.get("authorization");
46
46
  if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
47
+ log.warn(
48
+ { method: req.method, path: url.pathname },
49
+ "Runtime proxy auth rejected: missing or malformed Authorization header",
50
+ );
47
51
  return Response.json({ error: "Unauthorized" }, { status: 401 });
48
52
  }
49
53
  const edgeJwt = authHeader.slice(7);
50
54
  const result = validateEdgeToken(edgeJwt);
51
55
  if (!result.ok) {
52
- log.debug({ reason: result.reason }, "Edge token validation failed");
56
+ log.warn(
57
+ { method: req.method, path: url.pathname, reason: result.reason },
58
+ "Runtime proxy auth rejected: edge token validation failed",
59
+ );
53
60
  return Response.json({ error: "Unauthorized" }, { status: 401 });
54
61
  }
55
62
  exchangeToken = mintExchangeToken(
@@ -330,6 +330,10 @@ export function createSlackDeliverHandler(
330
330
  config: GatewayConfig,
331
331
  onThreadReply?: (threadTs: string) => void,
332
332
  caches?: { credentials?: CredentialCache; configFile?: ConfigFileCache },
333
+ pendingApprovalReplacements?: Map<
334
+ string,
335
+ { messageTs: string; expiresAt: number }
336
+ >,
333
337
  ) {
334
338
  return async (req: Request): Promise<Response> => {
335
339
  const traceId = req.headers.get("x-trace-id") ?? undefined;
@@ -541,8 +545,31 @@ export function createSlackDeliverHandler(
541
545
 
542
546
  // Support threading via query param
543
547
  const threadTs = new URL(req.url).searchParams.get("threadTs") ?? undefined;
544
- const messageTs = body.messageTs ?? updateTs;
545
- const isUpdate = typeof messageTs === "string" && messageTs.length > 0;
548
+ let messageTs = body.messageTs ?? updateTs;
549
+ let isUpdate = typeof messageTs === "string" && messageTs.length > 0;
550
+
551
+ // Check for pending approval message replacement: if this is a new message
552
+ // (not already an update) to a thread with a pending approval replacement,
553
+ // convert it to an update of the approval message so the follow-up content
554
+ // replaces the original approval prompt.
555
+ let isApprovalReplacement = false;
556
+ if (threadTs && !isUpdate && !isEphemeral && !chatAction && text) {
557
+ const replacementKey = `${chatId}:${threadTs}`;
558
+ const pending = pendingApprovalReplacements?.get(replacementKey);
559
+ if (pending && pending.expiresAt > Date.now()) {
560
+ messageTs = pending.messageTs;
561
+ isUpdate = true;
562
+ isApprovalReplacement = true;
563
+ pendingApprovalReplacements!.delete(replacementKey);
564
+ tlog.info(
565
+ { chatId, threadTs, approvalMessageTs: messageTs },
566
+ "Converting delivery to approval message replacement",
567
+ );
568
+ } else if (pending) {
569
+ // Expired — clean up stale entry
570
+ pendingApprovalReplacements!.delete(replacementKey);
571
+ }
572
+ }
546
573
 
547
574
  // Resolve Block Kit blocks: use provided blocks, approval prompt, or auto-format text
548
575
  const blocks: Block[] =
@@ -804,7 +831,7 @@ export function createSlackDeliverHandler(
804
831
  text,
805
832
  ts: messageTs,
806
833
  };
807
- if (blocks.length > 0) {
834
+ if (blocks.length > 0 || isApprovalReplacement) {
808
835
  updateBody.blocks = blocks;
809
836
  }
810
837
  result = await callSlackApiWithRetries(
@@ -436,6 +436,27 @@ export function createTelegramWebhookHandler(
436
436
  });
437
437
  } else {
438
438
  tlog.info({ status: "forwarded" }, "Forwarded /start to runtime");
439
+
440
+ // Fallback: if the runtime denied the message and could not
441
+ // deliver the rejection reply via callback, send it directly.
442
+ const startRuntimeResp = result.runtimeResponse;
443
+ if (startRuntimeResp?.denied && startRuntimeResp.replyText) {
444
+ sendTelegramReply(
445
+ config,
446
+ normalized.message.conversationExternalId,
447
+ startRuntimeResp.replyText,
448
+ undefined,
449
+ {
450
+ credentials: caches?.credentials,
451
+ configFile: caches?.configFile,
452
+ },
453
+ ).catch((err) => {
454
+ tlog.error(
455
+ { err, chatId: normalized.message.conversationExternalId },
456
+ "Failed to send ACL denial fallback reply",
457
+ );
458
+ });
459
+ }
439
460
  }
440
461
  } catch (err) {
441
462
  if (err instanceof CircuitBreakerOpenError) {
@@ -703,6 +724,21 @@ export function createTelegramWebhookHandler(
703
724
 
704
725
  tlog.info({ status: "forwarded" }, "Forwarded to runtime");
705
726
 
727
+ // Fallback: if the runtime denied the message and could not
728
+ // deliver the rejection reply via callback, send it directly.
729
+ const runtimeResp = result.runtimeResponse;
730
+ if (runtimeResp?.denied && runtimeResp.replyText) {
731
+ sendTelegramReply(config, chatId, runtimeResp.replyText, undefined, {
732
+ credentials: caches?.credentials,
733
+ configFile: caches?.configFile,
734
+ }).catch((err) => {
735
+ tlog.error(
736
+ { err, chatId },
737
+ "Failed to send ACL denial fallback reply",
738
+ );
739
+ });
740
+ }
741
+
706
742
  // Acknowledge the callback query to clear the button spinner in the
707
743
  // Telegram client. Best-effort — log errors but don't fail the flow.
708
744
  if (isCallback)
package/src/index.ts CHANGED
@@ -241,12 +241,28 @@ async function main() {
241
241
  credentials: credentialCache,
242
242
  configFile: configFileCache,
243
243
  });
244
+ // Map: "channel:threadTs" -> { messageTs, expiresAt } for replacing approval
245
+ // messages with the bot's follow-up content after an approval button click.
246
+ const pendingApprovalReplacements = new Map<
247
+ string,
248
+ { messageTs: string; expiresAt: number }
249
+ >();
250
+
251
+ // Clean up expired entries every 60s
252
+ setInterval(() => {
253
+ const now = Date.now();
254
+ for (const [key, entry] of pendingApprovalReplacements) {
255
+ if (entry.expiresAt <= now) pendingApprovalReplacements.delete(key);
256
+ }
257
+ }, 60_000);
258
+
244
259
  const handleSlackDeliver = createSlackDeliverHandler(
245
260
  config,
246
261
  (threadTs) => {
247
262
  slackSocketClient?.trackThread(threadTs);
248
263
  },
249
264
  { credentials: credentialCache, configFile: configFileCache },
265
+ pendingApprovalReplacements,
250
266
  );
251
267
  const handleOAuthCallback = createOAuthCallbackHandler(config);
252
268
  const pairingProxy = createPairingProxyHandler(config);
@@ -613,12 +629,20 @@ async function main() {
613
629
  const authHeader = req.headers.get("authorization");
614
630
  if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
615
631
  authRateLimiter.recordFailure(getClientIp());
632
+ log.warn(
633
+ { path: new URL(req.url).pathname },
634
+ "Guardian refresh auth rejected: missing or malformed Authorization header",
635
+ );
616
636
  return Response.json({ error: "Unauthorized" }, { status: 401 });
617
637
  }
618
638
  const token = authHeader.slice(7);
619
639
  const result = validateEdgeToken(token, { allowExpired: true });
620
640
  if (!result.ok) {
621
641
  authRateLimiter.recordFailure(getClientIp());
642
+ log.warn(
643
+ { path: new URL(req.url).pathname, reason: result.reason },
644
+ "Guardian refresh auth rejected: token validation failed",
645
+ );
622
646
  return Response.json({ error: "Unauthorized" }, { status: 401 });
623
647
  }
624
648
  return channelVerificationSessionProxy.handleGuardianRefresh(req);
@@ -1193,6 +1217,18 @@ async function main() {
1193
1217
  } else {
1194
1218
  forward();
1195
1219
  }
1220
+
1221
+ // When an approval button is clicked, store the approval message ts
1222
+ // so the next outbound delivery to this thread replaces the approval
1223
+ // message instead of posting a new one.
1224
+ const callbackData = normalized.event.message.callbackData;
1225
+ if (callbackData?.startsWith("apr:") && messageTs && threadTs) {
1226
+ const key = `${channel}:${threadTs}`;
1227
+ pendingApprovalReplacements.set(key, {
1228
+ messageTs,
1229
+ expiresAt: Date.now() + 60_000, // 60s TTL
1230
+ });
1231
+ }
1196
1232
  },
1197
1233
  );
1198
1234
 
@@ -179,6 +179,14 @@ export type RuntimeInboundResponse = {
179
179
  timestamp: string;
180
180
  attachments: RuntimeAttachmentMeta[];
181
181
  };
182
+ /** When true, the runtime denied the inbound message (e.g. ACL rejection). */
183
+ denied?: boolean;
184
+ /**
185
+ * A user-facing rejection message that the runtime could not deliver via
186
+ * the callback URL (e.g. due to auth failure). When present, the gateway
187
+ * should deliver it directly to the channel.
188
+ */
189
+ replyText?: string;
182
190
  };
183
191
 
184
192
  export type ForwardOptions = {
package/src/schema.ts CHANGED
@@ -1738,7 +1738,7 @@ export function buildSchema(): Record<string, unknown> {
1738
1738
  required: true,
1739
1739
  schema: { type: "string" },
1740
1740
  description:
1741
- "OAuth provider key to filter by, for example `integration:google`.",
1741
+ "OAuth provider key to filter by, for example `google`.",
1742
1742
  },
1743
1743
  ],
1744
1744
  security: [{ BearerAuth: [] }],
@@ -148,7 +148,21 @@ export async function reconcileTelegramWebhook(
148
148
  return;
149
149
  }
150
150
 
151
- const expectedUrl = await resolveExpectedTelegramWebhookUrl(caches);
151
+ let expectedUrl: string | undefined;
152
+ try {
153
+ expectedUrl = await resolveExpectedTelegramWebhookUrl(caches);
154
+ } catch (err) {
155
+ // Managed callback route registration failed — this is a platform-side
156
+ // issue. Do not suggest ngrok or other tunnel options; they are not
157
+ // usable in containerized deployments.
158
+ const detail = err instanceof Error ? err.message : String(err);
159
+ log.error(
160
+ { err },
161
+ `Telegram webhook registration failed: managed platform callback route could not be registered. ` +
162
+ `Please contact support. (${detail})`,
163
+ );
164
+ return;
165
+ }
152
166
  if (!expectedUrl) {
153
167
  log.debug(
154
168
  "Skipping webhook reconciliation: no public ingress or managed callback route available",