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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')
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 @@ -31,6 +32,7 @@ export const appRouter = router({
workItems: workItemsRouter,
users: usersRouter,
claudeCodeLimits: claudeCodeLimitsRouter,
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 };
}),
});
39 changes: 32 additions & 7 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 { adminProcedure, router } from '../trpc.js';
import {
Expand Down Expand Up @@ -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.',
};
Expand All @@ -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(),
Expand All @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -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: [],
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 @@ -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<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 @@ -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<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,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,
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading