Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -35,6 +36,7 @@ export const appRouter = router({
workItems: workItemsRouter,
users: usersRouter,
workflowStatuses: workflowStatusesRouter,
system: systemRouter,
});

export type AppRouter = typeof appRouter;
8 changes: 8 additions & 0 deletions src/api/routers/system.ts
Original file line number Diff line number Diff line change
@@ -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 };
}),
});
48 changes: 35 additions & 13 deletions src/api/routers/webhooks.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.',
};
Expand All @@ -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(),
Expand All @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -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: [],
Expand Down
7 changes: 6 additions & 1 deletion src/cli/dashboard/webhooks/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
if (flags['github-token']) oneTimeTokens.github = flags['github-token'];
Expand Down
7 changes: 6 additions & 1 deletion src/cli/dashboard/webhooks/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
if (flags['github-token']) oneTimeTokens.github = flags['github-token'];
Expand Down
4 changes: 3 additions & 1 deletion src/cli/dashboard/webhooks/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
55 changes: 55 additions & 0 deletions tests/unit/api/routers/system.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
129 changes: 128 additions & 1 deletion tests/unit/api/routers/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading