diff --git a/CLAUDE.md b/CLAUDE.md index b4c68f24..8d2d59a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,7 +201,7 @@ Optional (infrastructure): - `DATABASE_CA_CERT` - Path to a PEM-encoded CA certificate file for managed databases that use a private CA (e.g., AWS RDS, Azure Database, GCP Cloud SQL). When set, the certificate is read and passed as the `ca` option to `pg.Pool`, enabling TLS certificate validation against the specified CA. Example: `DATABASE_CA_CERT=/etc/ssl/certs/rds-ca.pem` - `CLAUDE_CODE_OAUTH_TOKEN` - For Claude Code engine (subscription auth) - `CREDENTIAL_MASTER_KEY` - 64-char hex string (32-byte AES-256 key) for encrypting credentials at rest. Generate with `npm run credentials:generate-key`. When set, all new/updated credentials are encrypted automatically; existing plaintext credentials continue to work. -- `WEBHOOK_CALLBACK_BASE_URL` - Base URL for webhook callbacks (e.g., `https://cascade.example.com`). Used by `tools/setup-webhooks.ts` and the `cascade webhooks create` CLI command to construct the full webhook URL. +- `WEBHOOK_CALLBACK_BASE_URL` - Public-facing base URL for webhook callbacks (e.g., `https://cascade.example.com`). Serves two purposes: (1) the default callback URL used when creating or deleting webhooks via the CLI or Dashboard — **required when CASCADE is deployed behind NAT or a reverse proxy** where the internal service URL differs from the public URL; (2) Trello HMAC signature verification (legacy). When set, the `cascade webhooks create` CLI command and Dashboard webhook management UI use this URL automatically without requiring `--callback-url` to be passed on every call. The `system.getPublicUrl` tRPC endpoint exposes this value to frontend clients. - `GITHUB_WEBHOOK_SECRET` - Optional HMAC secret for GitHub webhook signature verification. When set as an integration credential (`webhook_secret` role on the GitHub SCM integration), all newly created GitHub webhooks will include the secret so GitHub signs each delivery. The router then verifies the `X-Hub-Signature-256` header on incoming payloads. - `SENTRY_DSN` - Sentry DSN for error monitoring (router + worker) - `SENTRY_ENVIRONMENT` - Sentry environment tag (default: NODE_ENV or 'production') diff --git a/src/api/router.ts b/src/api/router.ts index afd5b1e7..fd080d7c 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'; @@ -31,6 +32,7 @@ export const appRouter = router({ workItems: workItemsRouter, users: usersRouter, claudeCodeLimits: claudeCodeLimitsRouter, + 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 5d39c263..21b8cf44 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 { adminProcedure, router } from '../trpc.js'; import { @@ -46,10 +47,12 @@ export const webhooksRouter = router({ // Sentry — informational only (webhooks must be configured in Sentry UI) let sentry: SentryWebhookInfo | null = null; - if (input.callbackBaseUrl && pctx.sentryConfigured) { - const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); + const listEffectiveBaseUrl = + (input.callbackBaseUrl ?? process.env.WEBHOOK_CALLBACK_BASE_URL ?? '').replace(/\/$/, '') || + null; + if (listEffectiveBaseUrl && pctx.sentryConfigured) { sentry = { - url: `${baseUrl}/sentry/webhook/${input.projectId}`, + url: `${listEffectiveBaseUrl}/sentry/webhook/${input.projectId}`, webhookSecretSet: pctx.sentryWebhookSecretSet ?? false, note: 'Configure this URL in your Sentry Internal Integration webhook settings.', }; @@ -74,7 +77,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(), gitlabOnly: z.boolean().optional(), @@ -85,7 +88,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; github?: GitHubWebhook | string; @@ -191,7 +205,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(), gitlabOnly: z.boolean().optional(), @@ -202,7 +216,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[]; gitlab: number[]; jira: number[] } = { trello: [], github: [], diff --git a/src/cli/dashboard/webhooks/create.ts b/src/cli/dashboard/webhooks/create.ts index e7fc7235..be68856a 100644 --- a/src/cli/dashboard/webhooks/create.ts +++ b/src/cli/dashboard/webhooks/create.ts @@ -30,7 +30,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 44c3debc..aa9cf237 100644 --- a/src/cli/dashboard/webhooks/delete.ts +++ b/src/cli/dashboard/webhooks/delete.ts @@ -29,7 +29,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 f28be15d..bc1351d0 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 46cfc2c1..941578f8 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createMockUser } from '../../../helpers/factories.js'; import { createCallerFor, @@ -269,6 +269,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 5e12f391..ec8e275e 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); @@ -151,16 +172,17 @@ 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, gitlabOnly: false, @@ -168,7 +190,20 @@ describe('WebhooksCreate (webhooks create)', () => { }); }); - 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); @@ -178,6 +213,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', @@ -200,7 +237,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, gitlabOnly: false, @@ -233,16 +270,17 @@ 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, gitlabOnly: false, @@ -250,7 +288,20 @@ describe('WebhooksDelete (webhooks delete)', () => { }); }); - 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); @@ -260,6 +311,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', @@ -282,7 +335,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, gitlabOnly: false, diff --git a/web/src/components/projects/integration-alerting-tab.tsx b/web/src/components/projects/integration-alerting-tab.tsx index d36745ed..a56de5cf 100644 --- a/web/src/components/projects/integration-alerting-tab.tsx +++ b/web/src/components/projects/integration-alerting-tab.tsx @@ -36,8 +36,10 @@ export function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps 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 a6373398..c3bb908f 100644 --- a/web/src/components/projects/integration-scm-tab.tsx +++ b/web/src/components/projects/integration-scm-tab.tsx @@ -163,8 +163,10 @@ function GitLabCredentialSlots({ projectId }: { projectId: string }) { function GitHubWebhookSection({ projectId }: { projectId: string }) { const queryClient = useQueryClient(); + 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 })); @@ -346,8 +348,10 @@ function GitHubWebhookSection({ projectId }: { projectId: string }) { // ============================================================================ function GitLabWebhookSection({ projectId }: { projectId: string }) { + 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 webhookCallbackUrl = callbackBaseUrl diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 749be152..6f2ca0d4 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -3,7 +3,7 @@ * Each hook encapsulates one concern to keep the main orchestrator thin. */ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; import { API_URL } from '@/lib/api.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; @@ -255,15 +255,18 @@ export function useVerification( export function useWebhookManagement(projectId: string, state: WizardState) { const queryClient = useQueryClient(); + + 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 createWebhookMutation = useMutation({ mutationFn: () => trpcClient.webhooks.create.mutate({ projectId, - callbackBaseUrl, + callbackBaseUrl: callbackBaseUrl || undefined, trelloOnly: state.provider === 'trello' ? true : undefined, jiraOnly: state.provider === 'jira' ? true : undefined, }), @@ -278,7 +281,7 @@ export function useWebhookManagement(projectId: string, state: WizardState) { mutationFn: (deleteCallbackBaseUrl: string) => trpcClient.webhooks.delete.mutate({ projectId, - callbackBaseUrl: deleteCallbackBaseUrl, + callbackBaseUrl: deleteCallbackBaseUrl || undefined, trelloOnly: state.provider === 'trello' ? true : undefined, jiraOnly: state.provider === 'jira' ? true : undefined, }),