From 12fe04075fec9461b996aec34320e6b6b5edf1dd Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 9 Apr 2026 23:03:04 +0000 Subject: [PATCH 1/2] feat(webhooks): use WEBHOOK_CALLBACK_BASE_URL as server-side fallback for webhook management --- CLAUDE.md | 2 + src/api/router.ts | 2 + src/api/routers/system.ts | 8 ++ src/api/routers/webhooks.ts | 48 +++++-- src/cli/dashboard/webhooks/create.ts | 7 +- src/cli/dashboard/webhooks/delete.ts | 7 +- src/cli/dashboard/webhooks/list.ts | 4 +- tests/unit/api/routers/system.test.ts | 55 ++++++++ tests/unit/api/routers/webhooks.test.ts | 129 +++++++++++++++++- .../cli/dashboard/webhooks/webhooks.test.ts | 71 ++++++++-- .../projects/integration-alerting-tab.tsx | 4 +- .../projects/integration-scm-tab.tsx | 4 +- 12 files changed, 313 insertions(+), 28 deletions(-) create mode 100644 src/api/routers/system.ts create mode 100644 tests/unit/api/routers/system.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index af7c2c9c6..a8a77ddad 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 9982ebd60..0853e6232 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 000000000..654e9db25 --- /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 f867109a3..b5104de80 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 b381407cb..d1219efd8 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 dfc649094..461140ffd 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 55a73e7db..9f2e54594 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 000000000..16509dbe0 --- /dev/null +++ b/tests/unit/api/routers/system.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, 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 8e768273a..2942e8eaa 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 cdb86e716..376a10e80 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 6f4e9e6ec..7f82b94d8 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 ef9a9d4e4..70328a0c2 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 })); From 0e52a7a64dceba86c2a9e68eec0ad38a635b9fec Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Apr 2026 17:24:40 +0000 Subject: [PATCH 2/2] fix(tests): remove unused beforeEach import from system.test.ts Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/api/routers/system.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/api/routers/system.test.ts b/tests/unit/api/routers/system.test.ts index 16509dbe0..57f2bb775 100644 --- a/tests/unit/api/routers/system.test.ts +++ b/tests/unit/api/routers/system.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +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';