diff --git a/CLAUDE.md b/CLAUDE.md index af7c2c9c..a8a77dda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,6 +196,8 @@ Required: Optional: +- `WEBHOOK_CALLBACK_BASE_URL` — public-facing base URL for webhook callbacks (e.g. `https://cascade.example.com`). Used as the server-side default callback URL when creating/deleting webhooks via the CLI or Dashboard (required behind NAT / reverse proxy where the internal service URL differs from the public URL), and for Trello HMAC signature verification. The `system.getPublicUrl` tRPC endpoint exposes it to frontend clients. + - `DATABASE_SSL` — `false` disables SSL (local dev); `no-verify` keeps TLS but skips certificate verification — required for managed Postgres that requires TLS yet presents a self-signed/private-CA cert (e.g. Supabase's connection pooler), where `DATABASE_CA_CERT` can't help because spawned worker containers get `DATABASE_*` env but no mounted cert file; unset → TLS with verification. `DATABASE_CA_CERT` pins a CA for managed DBs with a private CA (verification mode only). - `CREDENTIAL_MASTER_KEY` — 64-char hex (AES-256 key) to encrypt project credentials at rest. Without it, credentials are stored as plaintext; both modes coexist. - `GITHUB_WEBHOOK_SECRET` — opt-in HMAC verification; store as the `webhook_secret` role on the GitHub SCM integration. diff --git a/src/api/router.ts b/src/api/router.ts index 9982ebd6..0853e623 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -9,6 +9,7 @@ import { projectsRouter } from './routers/projects.js'; import { promptsRouter } from './routers/prompts.js'; import { prsRouter } from './routers/prs.js'; import { runsRouter } from './routers/runs.js'; +import { systemRouter } from './routers/system.js'; import { usersRouter } from './routers/users.js'; import { webhookLogsRouter } from './routers/webhookLogs.js'; import { webhooksRouter } from './routers/webhooks.js'; @@ -35,6 +36,7 @@ export const appRouter = router({ workItems: workItemsRouter, users: usersRouter, workflowStatuses: workflowStatusesRouter, + system: systemRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/api/routers/system.ts b/src/api/routers/system.ts new file mode 100644 index 00000000..654e9db2 --- /dev/null +++ b/src/api/routers/system.ts @@ -0,0 +1,8 @@ +import { protectedProcedure, router } from '../trpc.js'; + +export const systemRouter = router({ + getPublicUrl: protectedProcedure.query(() => { + const routerPublicUrl = process.env.WEBHOOK_CALLBACK_BASE_URL ?? null; + return { routerPublicUrl }; + }), +}); diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index f867109a..b5104de8 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -1,3 +1,4 @@ +import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { logger } from '../../utils/logging.js'; import { adminProcedure, router } from '../trpc.js'; @@ -158,20 +159,19 @@ export const webhooksRouter = router({ jiraListWebhooks(pctx), ]); - const sentry = input.callbackBaseUrl - ? (buildSentryDisplayInfo( - pctx, - input.projectId, - input.callbackBaseUrl.replace(/\/$/, ''), - ) ?? null) + const listEffectiveBaseUrl = + (input.callbackBaseUrl ?? process.env.WEBHOOK_CALLBACK_BASE_URL ?? '').replace(/\/$/, '') || + null; + + const sentry = listEffectiveBaseUrl + ? (buildSentryDisplayInfo(pctx, input.projectId, listEffectiveBaseUrl) ?? null) : null; // Linear — informational only (webhooks must be configured in Linear team settings) let linear: LinearWebhookInfo | null = null; - if (input.callbackBaseUrl && pctx.pmType === 'linear' && pctx.linearApiKey) { - const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); + if (listEffectiveBaseUrl && pctx.pmType === 'linear' && pctx.linearApiKey) { linear = { - url: `${baseUrl}/linear/webhook`, + url: `${listEffectiveBaseUrl}/linear/webhook`, webhookSecretSet: pctx.linearWebhookSecretSet ?? false, note: 'Configure this URL in your Linear team settings under API > Webhooks.', }; @@ -196,7 +196,7 @@ export const webhooksRouter = router({ .input( z.object({ projectId: z.string(), - callbackBaseUrl: z.string().url(), + callbackBaseUrl: z.string().url().optional(), trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), jiraOnly: z.boolean().optional(), @@ -206,7 +206,18 @@ export const webhooksRouter = router({ .mutation(async ({ ctx, input }) => { const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); applyOneTimeTokens(pctx, input.oneTimeTokens); - const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); + const baseUrl = ( + input.callbackBaseUrl ?? + process.env.WEBHOOK_CALLBACK_BASE_URL ?? + '' + ).replace(/\/$/, ''); + if (!baseUrl) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'callbackBaseUrl is required or set WEBHOOK_CALLBACK_BASE_URL env var on the server', + }); + } const results: { trello?: TrelloWebhook | string; @@ -240,7 +251,7 @@ export const webhooksRouter = router({ .input( z.object({ projectId: z.string(), - callbackBaseUrl: z.string().url(), + callbackBaseUrl: z.string().url().optional(), trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), jiraOnly: z.boolean().optional(), @@ -250,7 +261,18 @@ export const webhooksRouter = router({ .mutation(async ({ ctx, input }) => { const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); applyOneTimeTokens(pctx, input.oneTimeTokens); - const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); + const baseUrl = ( + input.callbackBaseUrl ?? + process.env.WEBHOOK_CALLBACK_BASE_URL ?? + '' + ).replace(/\/$/, ''); + if (!baseUrl) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'callbackBaseUrl is required or set WEBHOOK_CALLBACK_BASE_URL env var on the server', + }); + } const deleted: { trello: string[]; github: number[]; jira: number[] } = { trello: [], github: [], diff --git a/src/cli/dashboard/webhooks/create.ts b/src/cli/dashboard/webhooks/create.ts index b381407c..d1219efd 100644 --- a/src/cli/dashboard/webhooks/create.ts +++ b/src/cli/dashboard/webhooks/create.ts @@ -29,7 +29,12 @@ export default class WebhooksCreate extends DashboardCommand { const { args, flags } = await this.parse(WebhooksCreate); try { - const callbackBaseUrl = flags['callback-url'] || this.cliConfig.serverUrl; + let callbackBaseUrl: string | undefined = flags['callback-url'] || undefined; + if (!callbackBaseUrl) { + // Try to get the public URL configured on the server + const { routerPublicUrl } = await this.client.system.getPublicUrl.query(); + callbackBaseUrl = routerPublicUrl ?? undefined; + } const oneTimeTokens: Record = {}; if (flags['github-token']) oneTimeTokens.github = flags['github-token']; diff --git a/src/cli/dashboard/webhooks/delete.ts b/src/cli/dashboard/webhooks/delete.ts index dfc64909..461140ff 100644 --- a/src/cli/dashboard/webhooks/delete.ts +++ b/src/cli/dashboard/webhooks/delete.ts @@ -28,7 +28,12 @@ export default class WebhooksDelete extends DashboardCommand { const { args, flags } = await this.parse(WebhooksDelete); try { - const callbackBaseUrl = flags['callback-url'] || this.cliConfig.serverUrl; + let callbackBaseUrl: string | undefined = flags['callback-url'] || undefined; + if (!callbackBaseUrl) { + // Try to get the public URL configured on the server + const { routerPublicUrl } = await this.client.system.getPublicUrl.query(); + callbackBaseUrl = routerPublicUrl ?? undefined; + } const oneTimeTokens: Record = {}; if (flags['github-token']) oneTimeTokens.github = flags['github-token']; diff --git a/src/cli/dashboard/webhooks/list.ts b/src/cli/dashboard/webhooks/list.ts index 55a73e7d..9f2e5459 100644 --- a/src/cli/dashboard/webhooks/list.ts +++ b/src/cli/dashboard/webhooks/list.ts @@ -31,9 +31,11 @@ export default class WebhooksList extends DashboardCommand { if (flags['jira-email']) oneTimeTokens.jiraEmail = flags['jira-email']; if (flags['jira-api-token']) oneTimeTokens.jiraApiToken = flags['jira-api-token']; + // Prefer the server-configured public URL for Sentry display + const { routerPublicUrl } = await this.client.system.getPublicUrl.query(); const result = await this.client.webhooks.list.query({ projectId: args.projectId, - callbackBaseUrl: this.cliConfig.serverUrl || undefined, + callbackBaseUrl: routerPublicUrl ?? undefined, oneTimeTokens: Object.keys(oneTimeTokens).length > 0 ? oneTimeTokens : undefined, }); diff --git a/tests/unit/api/routers/system.test.ts b/tests/unit/api/routers/system.test.ts new file mode 100644 index 00000000..57f2bb77 --- /dev/null +++ b/tests/unit/api/routers/system.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { systemRouter } from '../../../../src/api/routers/system.js'; +import { createMockUser } from '../../../helpers/factories.js'; +import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; + +const createCaller = createCallerFor(systemRouter); + +const mockUser = createMockUser(); + +describe('systemRouter', () => { + describe('getPublicUrl', () => { + const originalEnv = process.env.WEBHOOK_CALLBACK_BASE_URL; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.WEBHOOK_CALLBACK_BASE_URL; + } else { + process.env.WEBHOOK_CALLBACK_BASE_URL = originalEnv; + } + }); + + it('returns the WEBHOOK_CALLBACK_BASE_URL when set', async () => { + process.env.WEBHOOK_CALLBACK_BASE_URL = 'https://cascade.example.com'; + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.getPublicUrl(); + + expect(result).toEqual({ routerPublicUrl: 'https://cascade.example.com' }); + }); + + it('returns null when WEBHOOK_CALLBACK_BASE_URL is not set', async () => { + delete process.env.WEBHOOK_CALLBACK_BASE_URL; + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.getPublicUrl(); + + expect(result).toEqual({ routerPublicUrl: null }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError(caller.getPublicUrl(), 'UNAUTHORIZED'); + }); + + it('returns the URL for member role (protected procedure, not admin-only)', async () => { + process.env.WEBHOOK_CALLBACK_BASE_URL = 'https://cascade.example.com'; + const memberUser = createMockUser({ role: 'member' }); + const caller = createCaller({ user: memberUser, effectiveOrgId: memberUser.orgId }); + + const result = await caller.getPublicUrl(); + + expect(result.routerPublicUrl).toBe('https://cascade.example.com'); + }); + }); +}); diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index 8e768273..2942e8ea 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -1,5 +1,5 @@ import { TRPCError } from '@trpc/server'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createMockUser } from '../../../helpers/factories.js'; import { createCallerFor, @@ -382,6 +382,133 @@ describe('webhooksRouter', () => { }); }); + describe('create — callbackBaseUrl fallback', () => { + const originalEnv = process.env.WEBHOOK_CALLBACK_BASE_URL; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.WEBHOOK_CALLBACK_BASE_URL; + } else { + process.env.WEBHOOK_CALLBACK_BASE_URL = originalEnv; + } + }); + + it('uses WEBHOOK_CALLBACK_BASE_URL env var when callbackBaseUrl not provided', async () => { + process.env.WEBHOOK_CALLBACK_BASE_URL = 'https://cascade.example.com'; + setupProjectContext({ noTrello: true }); + + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 1, + config: { url: 'https://cascade.example.com/github/webhook' }, + events: ['push'], + active: true, + }, + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ projectId: 'my-project' }); + + expect(result.github).toMatchObject({ id: 1 }); + expect(mockCreateWebhook).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + url: 'https://cascade.example.com/github/webhook', + }), + }), + ); + }); + + it('throws BAD_REQUEST when neither callbackBaseUrl nor env var is set', async () => { + delete process.env.WEBHOOK_CALLBACK_BASE_URL; + setupProjectContext({ noTrello: true }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.create({ projectId: 'my-project' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('prefers explicit callbackBaseUrl over env var', async () => { + process.env.WEBHOOK_CALLBACK_BASE_URL = 'https://env-url.example.com'; + setupProjectContext({ noTrello: true }); + + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 2, + config: { url: 'https://explicit.example.com/github/webhook' }, + events: ['push'], + active: true, + }, + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.create({ + projectId: 'my-project', + callbackBaseUrl: 'https://explicit.example.com', + }); + + expect(mockCreateWebhook).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + url: 'https://explicit.example.com/github/webhook', + }), + }), + ); + }); + }); + + describe('delete — callbackBaseUrl fallback', () => { + const originalEnv = process.env.WEBHOOK_CALLBACK_BASE_URL; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.WEBHOOK_CALLBACK_BASE_URL; + } else { + process.env.WEBHOOK_CALLBACK_BASE_URL = originalEnv; + } + }); + + it('uses WEBHOOK_CALLBACK_BASE_URL env var when callbackBaseUrl not provided', async () => { + process.env.WEBHOOK_CALLBACK_BASE_URL = 'https://cascade.example.com'; + setupProjectContext(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'tw-env', + callbackURL: 'https://cascade.example.com/trello/webhook', + idModel: 'board-123', + active: true, + }, + ]), + }) + .mockResolvedValueOnce({ ok: true }); // delete + + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.delete({ projectId: 'my-project' }); + + expect(result.trello).toEqual(['tw-env']); + }); + + it('throws BAD_REQUEST when neither callbackBaseUrl nor env var is set', async () => { + delete process.env.WEBHOOK_CALLBACK_BASE_URL; + setupProjectContext({ noTrello: true }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.delete({ projectId: 'my-project' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + describe('create', () => { it('creates both trello and github webhooks', async () => { setupProjectContext(); diff --git a/tests/unit/cli/dashboard/webhooks/webhooks.test.ts b/tests/unit/cli/dashboard/webhooks/webhooks.test.ts index cdb86e71..376a10e8 100644 --- a/tests/unit/cli/dashboard/webhooks/webhooks.test.ts +++ b/tests/unit/cli/dashboard/webhooks/webhooks.test.ts @@ -35,6 +35,11 @@ const baseConfig = { serverUrl: 'http://localhost:3001', sessionToken: 'tok' }; function makeClient(overrides: Record = {}) { return { + system: { + getPublicUrl: { + query: vi.fn().mockResolvedValue({ routerPublicUrl: 'http://localhost:3001' }), + }, + }, webhooks: { list: { query: vi.fn().mockResolvedValue({ @@ -68,13 +73,14 @@ describe('WebhooksList (webhooks list)', () => { mockLoadConfig.mockReturnValue(baseConfig); }); - it('lists webhooks for project ID', async () => { + it('lists webhooks for project ID using server public URL as callbackBaseUrl', async () => { const client = makeClient(); mockCreateDashboardClient.mockReturnValue(client); const cmd = new WebhooksList(['my-project'], oclifConfig as never); await cmd.run(); + expect(client.system.getPublicUrl.query).toHaveBeenCalled(); expect(client.webhooks.list.query).toHaveBeenCalledWith({ projectId: 'my-project', callbackBaseUrl: 'http://localhost:3001', @@ -82,6 +88,21 @@ describe('WebhooksList (webhooks list)', () => { }); }); + it('uses null callbackBaseUrl when server has no public URL configured', async () => { + const client = makeClient(); + client.system.getPublicUrl.query.mockResolvedValue({ routerPublicUrl: null }); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WebhooksList(['my-project'], oclifConfig as never); + await cmd.run(); + + expect(client.webhooks.list.query).toHaveBeenCalledWith({ + projectId: 'my-project', + callbackBaseUrl: undefined, + oneTimeTokens: undefined, + }); + }); + it('passes --github-token as oneTimeTokens when provided', async () => { const client = makeClient(); mockCreateDashboardClient.mockReturnValue(client); @@ -184,23 +205,37 @@ describe('WebhooksCreate (webhooks create)', () => { mockLoadConfig.mockReturnValue(baseConfig); }); - it('creates webhooks for project ID using server URL as callback base', async () => { + it('creates webhooks for project ID using server public URL as callback base', async () => { const client = makeClient(); mockCreateDashboardClient.mockReturnValue(client); const cmd = new WebhooksCreate(['my-project'], oclifConfig as never); await cmd.run(); + expect(client.system.getPublicUrl.query).toHaveBeenCalled(); expect(client.webhooks.create.mutate).toHaveBeenCalledWith({ projectId: 'my-project', - callbackBaseUrl: baseConfig.serverUrl, + callbackBaseUrl: 'http://localhost:3001', trelloOnly: false, githubOnly: false, oneTimeTokens: undefined, }); }); - it('passes --callback-url when provided', async () => { + it('uses undefined callbackBaseUrl when server has no public URL configured', async () => { + const client = makeClient(); + client.system.getPublicUrl.query.mockResolvedValue({ routerPublicUrl: null }); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WebhooksCreate(['my-project'], oclifConfig as never); + await cmd.run(); + + expect(client.webhooks.create.mutate).toHaveBeenCalledWith( + expect.objectContaining({ callbackBaseUrl: undefined }), + ); + }); + + it('passes --callback-url when provided (takes precedence over server URL)', async () => { const client = makeClient(); mockCreateDashboardClient.mockReturnValue(client); @@ -210,6 +245,8 @@ describe('WebhooksCreate (webhooks create)', () => { ); await cmd.run(); + // When --callback-url is provided, system.getPublicUrl should not be called + expect(client.system.getPublicUrl.query).not.toHaveBeenCalled(); expect(client.webhooks.create.mutate).toHaveBeenCalledWith({ projectId: 'my-project', callbackBaseUrl: 'https://cascade.example.com', @@ -231,7 +268,7 @@ describe('WebhooksCreate (webhooks create)', () => { expect(client.webhooks.create.mutate).toHaveBeenCalledWith({ projectId: 'my-project', - callbackBaseUrl: baseConfig.serverUrl, + callbackBaseUrl: 'http://localhost:3001', trelloOnly: false, githubOnly: false, oneTimeTokens: { github: 'ghp_testtoken123' }, @@ -291,23 +328,37 @@ describe('WebhooksDelete (webhooks delete)', () => { mockLoadConfig.mockReturnValue(baseConfig); }); - it('deletes webhooks for project ID using server URL as callback base', async () => { + it('deletes webhooks for project ID using server public URL as callback base', async () => { const client = makeClient(); mockCreateDashboardClient.mockReturnValue(client); const cmd = new WebhooksDelete(['my-project'], oclifConfig as never); await cmd.run(); + expect(client.system.getPublicUrl.query).toHaveBeenCalled(); expect(client.webhooks.delete.mutate).toHaveBeenCalledWith({ projectId: 'my-project', - callbackBaseUrl: baseConfig.serverUrl, + callbackBaseUrl: 'http://localhost:3001', trelloOnly: false, githubOnly: false, oneTimeTokens: undefined, }); }); - it('passes --callback-url when provided', async () => { + it('uses undefined callbackBaseUrl when server has no public URL configured', async () => { + const client = makeClient(); + client.system.getPublicUrl.query.mockResolvedValue({ routerPublicUrl: null }); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WebhooksDelete(['my-project'], oclifConfig as never); + await cmd.run(); + + expect(client.webhooks.delete.mutate).toHaveBeenCalledWith( + expect.objectContaining({ callbackBaseUrl: undefined }), + ); + }); + + it('passes --callback-url when provided (takes precedence over server URL)', async () => { const client = makeClient(); mockCreateDashboardClient.mockReturnValue(client); @@ -317,6 +368,8 @@ describe('WebhooksDelete (webhooks delete)', () => { ); await cmd.run(); + // When --callback-url is provided, system.getPublicUrl should not be called + expect(client.system.getPublicUrl.query).not.toHaveBeenCalled(); expect(client.webhooks.delete.mutate).toHaveBeenCalledWith({ projectId: 'my-project', callbackBaseUrl: 'https://cascade.example.com', @@ -338,7 +391,7 @@ describe('WebhooksDelete (webhooks delete)', () => { expect(client.webhooks.delete.mutate).toHaveBeenCalledWith({ projectId: 'my-project', - callbackBaseUrl: baseConfig.serverUrl, + callbackBaseUrl: 'http://localhost:3001', trelloOnly: false, githubOnly: false, oneTimeTokens: { github: 'ghp_testtoken123' }, diff --git a/web/src/components/projects/integration-alerting-tab.tsx b/web/src/components/projects/integration-alerting-tab.tsx index 6f4e9e6e..7f82b94d 100644 --- a/web/src/components/projects/integration-alerting-tab.tsx +++ b/web/src/components/projects/integration-alerting-tab.tsx @@ -232,8 +232,10 @@ export function AlertingTab({ const [verifyError, setVerifyError] = useState(null); const [isVerifying, setIsVerifying] = useState(false); + const publicUrlQuery = useQuery(trpc.system.getPublicUrl.queryOptions()); const callbackBaseUrl = - API_URL || + publicUrlQuery.data?.routerPublicUrl ?? + API_URL ?? (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); const sentryWebhookUrl = callbackBaseUrl diff --git a/web/src/components/projects/integration-scm-tab.tsx b/web/src/components/projects/integration-scm-tab.tsx index ef9a9d4e..70328a0c 100644 --- a/web/src/components/projects/integration-scm-tab.tsx +++ b/web/src/components/projects/integration-scm-tab.tsx @@ -138,8 +138,10 @@ function GitHubWebhookSection({ projectId }: { projectId: string }) { (c) => c.envVarKey === 'GITHUB_WEBHOOK_SECRET', ); + const publicUrlQuery = useQuery(trpc.system.getPublicUrl.queryOptions()); const callbackBaseUrl = - API_URL || + publicUrlQuery.data?.routerPublicUrl ?? + API_URL ?? (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId }));