fix(webhooks): use next public app url instead of request origin for webhook registration (#1596)

* fix(webhooks): use next public app url instead of request origin for webhook registration

* ack PR comments

* ci: pin Bun to v1.2.22 to avoid Bun 1.3 breaking changes
This commit is contained in:
Waleed
2025-10-10 17:10:20 -07:00
committed by waleed
parent 241d9fd12d
commit 923595f57e
7 changed files with 36 additions and 61 deletions

View File

@@ -16,7 +16,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with: with:
bun-version: latest bun-version: 1.2.22
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -282,11 +282,13 @@ export async function DELETE(
if (!resolvedExternalId) { if (!resolvedExternalId) {
try { try {
const requestOrigin = new URL(request.url).origin if (!env.NEXT_PUBLIC_APP_URL) {
const effectiveOrigin = requestOrigin.includes('localhost') logger.error(
? env.NEXT_PUBLIC_APP_URL || requestOrigin `[${requestId}] NEXT_PUBLIC_APP_URL not configured, cannot match Airtable webhook`
: requestOrigin )
const expectedNotificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${foundWebhook.path}` throw new Error('NEXT_PUBLIC_APP_URL must be configured')
}
const expectedNotificationUrl = `${env.NEXT_PUBLIC_APP_URL}/api/webhooks/trigger/${foundWebhook.path}`
const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const listResp = await fetch(listUrl, { const listResp = await fetch(listUrl, {

View File

@@ -432,25 +432,20 @@ async function createAirtableWebhookSubscription(
logger.warn( logger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.`
) )
// Instead of silently returning, throw an error with clear user guidance
throw new Error( throw new Error(
'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.'
) )
} }
const requestOrigin = new URL(request.url).origin if (!env.NEXT_PUBLIC_APP_URL) {
// Ensure origin does not point to localhost for external API calls logger.error(
const effectiveOrigin = requestOrigin.includes('localhost') `[${requestId}] NEXT_PUBLIC_APP_URL not configured, cannot register Airtable webhook`
? env.NEXT_PUBLIC_APP_URL || requestOrigin // Use env var if available, fallback to original
: requestOrigin
const notificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${path}`
if (effectiveOrigin !== requestOrigin) {
logger.debug(
`[${requestId}] Remapped localhost origin to ${effectiveOrigin} for notificationUrl`
) )
throw new Error('NEXT_PUBLIC_APP_URL must be configured for Airtable webhook registration')
} }
const notificationUrl = `${env.NEXT_PUBLIC_APP_URL}/api/webhooks/trigger/${path}`
const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const specification: any = { const specification: any = {
@@ -549,19 +544,15 @@ async function createTelegramWebhookSubscription(
return // Cannot proceed without botToken return // Cannot proceed without botToken
} }
const requestOrigin = new URL(request.url).origin if (!env.NEXT_PUBLIC_APP_URL) {
// Ensure origin does not point to localhost for external API calls logger.error(
const effectiveOrigin = requestOrigin.includes('localhost') `[${requestId}] NEXT_PUBLIC_APP_URL not configured, cannot register Telegram webhook`
? env.NEXT_PUBLIC_APP_URL || requestOrigin // Use env var if available, fallback to original
: requestOrigin
const notificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${path}`
if (effectiveOrigin !== requestOrigin) {
logger.debug(
`[${requestId}] Remapped localhost origin to ${effectiveOrigin} for notificationUrl`
) )
throw new Error('NEXT_PUBLIC_APP_URL must be configured for Telegram webhook registration')
} }
const notificationUrl = `${env.NEXT_PUBLIC_APP_URL}/api/webhooks/trigger/${path}`
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook` const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook`
const requestBody: any = { const requestBody: any = {

View File

@@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { webhook } from '@sim/db/schema' import { webhook } from '@sim/db/schema'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
@@ -13,7 +14,6 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId() const requestId = generateRequestId()
try { try {
// Get the webhook ID and provider from the query parameters
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const webhookId = searchParams.get('id') const webhookId = searchParams.get('id')
@@ -24,7 +24,6 @@ export async function GET(request: NextRequest) {
logger.debug(`[${requestId}] Testing webhook with ID: ${webhookId}`) logger.debug(`[${requestId}] Testing webhook with ID: ${webhookId}`)
// Find the webhook in the database
const webhooks = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1) const webhooks = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1)
if (webhooks.length === 0) { if (webhooks.length === 0) {
@@ -36,8 +35,14 @@ export async function GET(request: NextRequest) {
const provider = foundWebhook.provider || 'generic' const provider = foundWebhook.provider || 'generic'
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {} const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
// Construct the webhook URL if (!env.NEXT_PUBLIC_APP_URL) {
const baseUrl = new URL(request.url).origin logger.error(`[${requestId}] NEXT_PUBLIC_APP_URL not configured, cannot test webhook`)
return NextResponse.json(
{ success: false, error: 'NEXT_PUBLIC_APP_URL must be configured' },
{ status: 500 }
)
}
const baseUrl = env.NEXT_PUBLIC_APP_URL
const webhookUrl = `${baseUrl}/api/webhooks/trigger/${foundWebhook.path}` const webhookUrl = `${baseUrl}/api/webhooks/trigger/${foundWebhook.path}`
logger.info(`[${requestId}] Testing webhook for provider: ${provider}`, { logger.info(`[${requestId}] Testing webhook for provider: ${provider}`, {
@@ -46,7 +51,6 @@ export async function GET(request: NextRequest) {
isActive: foundWebhook.isActive, isActive: foundWebhook.isActive,
}) })
// Provider-specific test logic
switch (provider) { switch (provider) {
case 'whatsapp': { case 'whatsapp': {
const verificationToken = providerConfig.verificationToken const verificationToken = providerConfig.verificationToken
@@ -59,10 +63,8 @@ export async function GET(request: NextRequest) {
) )
} }
// Generate a test challenge
const challenge = `test_${Date.now()}` const challenge = `test_${Date.now()}`
// Construct the WhatsApp verification URL
const whatsappUrl = `${webhookUrl}?hub.mode=subscribe&hub.verify_token=${verificationToken}&hub.challenge=${challenge}` const whatsappUrl = `${webhookUrl}?hub.mode=subscribe&hub.verify_token=${verificationToken}&hub.challenge=${challenge}`
logger.debug(`[${requestId}] Testing WhatsApp webhook verification`, { logger.debug(`[${requestId}] Testing WhatsApp webhook verification`, {
@@ -70,19 +72,16 @@ export async function GET(request: NextRequest) {
challenge, challenge,
}) })
// Make a request to the webhook endpoint
const response = await fetch(whatsappUrl, { const response = await fetch(whatsappUrl, {
headers: { headers: {
'User-Agent': 'facebookplatform/1.0', 'User-Agent': 'facebookplatform/1.0',
}, },
}) })
// Get the response details
const status = response.status const status = response.status
const contentType = response.headers.get('content-type') const contentType = response.headers.get('content-type')
const responseText = await response.text() const responseText = await response.text()
// Check if the test was successful
const success = status === 200 && responseText === challenge const success = status === 200 && responseText === challenge
if (success) { if (success) {
@@ -139,7 +138,6 @@ export async function GET(request: NextRequest) {
) )
} }
// Test the webhook endpoint with a simple message to check if it's reachable
const testMessage = { const testMessage = {
update_id: 12345, update_id: 12345,
message: { message: {
@@ -165,7 +163,6 @@ export async function GET(request: NextRequest) {
url: webhookUrl, url: webhookUrl,
}) })
// Make a test request to the webhook endpoint
const response = await fetch(webhookUrl, { const response = await fetch(webhookUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -175,16 +172,12 @@ export async function GET(request: NextRequest) {
body: JSON.stringify(testMessage), body: JSON.stringify(testMessage),
}) })
// Get the response details
const status = response.status const status = response.status
let responseText = '' let responseText = ''
try { try {
responseText = await response.text() responseText = await response.text()
} catch (_e) { } catch (_e) {}
// Ignore if we can't get response text
}
// Consider success if we get a 2xx response
const success = status >= 200 && status < 300 const success = status >= 200 && status < 300
if (success) { if (success) {
@@ -196,7 +189,6 @@ export async function GET(request: NextRequest) {
}) })
} }
// Get webhook info from Telegram API
let webhookInfo = null let webhookInfo = null
try { try {
const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo` const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo`
@@ -215,7 +207,6 @@ export async function GET(request: NextRequest) {
logger.warn(`[${requestId}] Failed to get Telegram webhook info`, e) logger.warn(`[${requestId}] Failed to get Telegram webhook info`, e)
} }
// Format the curl command for testing
const curlCommand = [ const curlCommand = [
`curl -X POST "${webhookUrl}"`, `curl -X POST "${webhookUrl}"`,
`-H "Content-Type: application/json"`, `-H "Content-Type: application/json"`,
@@ -288,16 +279,13 @@ export async function GET(request: NextRequest) {
} }
case 'generic': { case 'generic': {
// Get the general webhook configuration
const token = providerConfig.token const token = providerConfig.token
const secretHeaderName = providerConfig.secretHeaderName const secretHeaderName = providerConfig.secretHeaderName
const requireAuth = providerConfig.requireAuth const requireAuth = providerConfig.requireAuth
const allowedIps = providerConfig.allowedIps const allowedIps = providerConfig.allowedIps
// Generate sample curl command for testing
let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"` let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"`
// Add auth headers to the curl command if required
if (requireAuth && token) { if (requireAuth && token) {
if (secretHeaderName) { if (secretHeaderName) {
curlCommand += ` -H "${secretHeaderName}: ${token}"` curlCommand += ` -H "${secretHeaderName}: ${token}"`
@@ -306,7 +294,6 @@ export async function GET(request: NextRequest) {
} }
} }
// Add a sample payload
curlCommand += ` -d '{"event":"test_event","timestamp":"${new Date().toISOString()}"}'` curlCommand += ` -d '{"event":"test_event","timestamp":"${new Date().toISOString()}"}'`
logger.info(`[${requestId}] General webhook test successful: ${webhookId}`) logger.info(`[${requestId}] General webhook test successful: ${webhookId}`)
@@ -391,7 +378,6 @@ export async function GET(request: NextRequest) {
}) })
} }
// Add the Airtable test case
case 'airtable': { case 'airtable': {
const baseId = providerConfig.baseId const baseId = providerConfig.baseId
const tableId = providerConfig.tableId const tableId = providerConfig.tableId
@@ -408,7 +394,6 @@ export async function GET(request: NextRequest) {
) )
} }
// Define a sample payload structure
const samplePayload = { const samplePayload = {
webhook: { webhook: {
id: 'whiYOUR_WEBHOOK_ID', id: 'whiYOUR_WEBHOOK_ID',
@@ -418,16 +403,15 @@ export async function GET(request: NextRequest) {
}, },
payloadFormat: 'v0', payloadFormat: 'v0',
actionMetadata: { actionMetadata: {
source: 'tableOrViewChange', // Example source source: 'tableOrViewChange',
sourceMetadata: {}, sourceMetadata: {},
}, },
payloads: [ payloads: [
{ {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
baseTransactionNumber: Date.now(), // Example transaction number baseTransactionNumber: Date.now(),
changedTablesById: { changedTablesById: {
[tableId]: { [tableId]: {
// Example changes - structure may vary based on actual event
changedRecordsById: { changedRecordsById: {
recSAMPLEID1: { recSAMPLEID1: {
current: { cellValuesByFieldId: { fldSAMPLEID: 'New Value' } }, current: { cellValuesByFieldId: { fldSAMPLEID: 'New Value' } },
@@ -442,7 +426,6 @@ export async function GET(request: NextRequest) {
], ],
} }
// Generate sample curl command
let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"` let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"`
curlCommand += ` -d '${JSON.stringify(samplePayload, null, 2)}'` curlCommand += ` -d '${JSON.stringify(samplePayload, null, 2)}'`
@@ -519,7 +502,6 @@ export async function GET(request: NextRequest) {
} }
default: { default: {
// Generic webhook test
logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`) logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`)
return NextResponse.json({ return NextResponse.json({
success: true, success: true,

View File

@@ -1,7 +1,7 @@
# ======================================== # ========================================
# Base Stage: Alpine Linux with Bun # Base Stage: Alpine Linux with Bun
# ======================================== # ========================================
FROM oven/bun:alpine AS base FROM oven/bun:1.2.22-alpine AS base
# ======================================== # ========================================
# Dependencies Stage: Install Dependencies # Dependencies Stage: Install Dependencies

View File

@@ -1,7 +1,7 @@
# ======================================== # ========================================
# Dependencies Stage: Install Dependencies # Dependencies Stage: Install Dependencies
# ======================================== # ========================================
FROM oven/bun:1.2.21-alpine AS deps FROM oven/bun:1.2.22-alpine AS deps
WORKDIR /app WORKDIR /app
# Copy only package files needed for migrations # Copy only package files needed for migrations
@@ -14,7 +14,7 @@ RUN bun install --ignore-scripts
# ======================================== # ========================================
# Runner Stage: Production Environment # Runner Stage: Production Environment
# ======================================== # ========================================
FROM oven/bun:1.2.21-alpine AS runner FROM oven/bun:1.2.22-alpine AS runner
WORKDIR /app WORKDIR /app
# Copy only the necessary files from deps # Copy only the necessary files from deps

View File

@@ -1,7 +1,7 @@
# ======================================== # ========================================
# Base Stage: Alpine Linux with Bun # Base Stage: Alpine Linux with Bun
# ======================================== # ========================================
FROM oven/bun:alpine AS base FROM oven/bun:1.2.22-alpine AS base
# ======================================== # ========================================
# Dependencies Stage: Install Dependencies # Dependencies Stage: Install Dependencies