Skip to content

Commit 9ec0449

Browse files
claudfuenclaude
andauthored
feat(pentest): subscription billing + GitHub repo selector (#2212)
* feat(pentest): subscription-based billing model Replaces the mock checkout redirect with an inline subscription billing model. Organizations subscribe to a monthly plan (3 runs included) and overage runs are charged immediately via Stripe PaymentIntent at run creation time — no redirect required. - Add PentestSubscription DB model (organization relation, period tracking) - Add billing server actions: subscribeToPentestPlan, handleSubscriptionSuccess, checkAndChargePentestBilling (blocks run creation on billing failure) - Add /[orgId]/security/penetration-tests/subscription management page - Add /api/webhooks/stripe-pentest webhook handler (subscription updated/deleted) - Remove mockCheckout from API DTO and client types - Update useCreatePenetrationTest to call billing check before API post - Update page client: remove checkout redirect/search-param handling, navigate directly to report detail on success - Add STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID, STRIPE_PENTEST_OVERAGE_PRICE_ID, STRIPE_PENTEST_WEBHOOK_SECRET env vars - Delete mock checkout page - Update all tests to reflect new flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(billing): use SubscriptionItem for period dates in Stripe SDK v20+ In stripe@20.x with API version 2025-12-15.clover, current_period_start and current_period_end were moved from the root Subscription type to SubscriptionItem. Read them via subscription.items.data[0] instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(security): add Pentest Billing tab and Stripe billing portal - Add "Pentest Billing" as a second tab in SecuritySidebar, pointing to the existing subscription management page - Fix active-tab detection so Penetration Tests doesn't stay highlighted when on the Billing sub-page - Add createBillingPortalSession server action so active subscribers can manage their payment method via Stripe's hosted portal - Fix return URL to use NEXT_PUBLIC_BETTER_AUTH_URL (was using NEXT_PUBLIC_APP_URL which isn't defined) - Fix subscribe button copy to reflect actual price ($99/month) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(pentest): address PR review findings P1 — billing auth: validate session and org membership in checkAndChargePentestBilling before any DB/Stripe calls, so a caller cannot trigger billing against an org they don't belong to. P1 — billing order: move checkAndChargePentestBilling to after successful run creation so a transient provider failure never charges the customer without delivering a run. Threshold adjusted from `<` to `<=` so the included-run count is still respected once the new run is counted in DB. P2 — GitHub token key: integration-platform GitHub connections use OAuth2 and store the token under `access_token`; fix getGithubTokenForOrg to read that field instead of the legacy PAT key `GITHUB_TOKEN`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(billing): lift stripeCustomerId to OrganizationBilling + Settings > Billing hub - Add OrganizationBilling model (one per org, owns stripeCustomerId) so future subscription products share a single Stripe customer - PentestSubscription now relates to OrganizationBilling via FK instead of owning stripeCustomerId directly - New Settings > Billing page as the single hub for all app subscriptions - Remove Pentest Billing tab from Security sidebar - Delete old /security/penetration-tests/subscription page - Update "Manage subscription" link to /settings/billing - Add requireOrgMember() guard to all four billing server actions (subscribeToPentestPlan, handleSubscriptionSuccess, createBillingPortalSession were previously missing the cross-tenant auth check) - Validate Stripe session ownership in handleSubscriptionSuccess: reject if session customer doesn't match existing org billing record Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(webhook): handle checkout.session.completed to activate subscription server-side Previously, PentestSubscription was only created when the user returned to the /settings/billing success URL. If they closed the tab or the browser crashed after Stripe Checkout, the subscription would never activate. The webhook is now the primary activation path: - checkout.session.completed → look up OrganizationBilling by stripeCustomerId, retrieve full subscription, upsert PentestSubscription - handleSubscriptionSuccess on the return URL becomes an idempotent fallback (safe to call again since both paths use upsert) Note: checkout.session.completed must be added to the Stripe webhook's event subscriptions in the dashboard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(billing): harden Stripe customer binding and URL construction - Remove findStripeCustomerByDomain fallback in subscribeToPentestPlan: organization.website is tenant-controlled so domain-based customer reuse could let a malicious org bind to another company's Stripe customer. Always create a fresh customer when no billing row exists. - Strengthen handleSubscriptionSuccess session ownership check: previously only rejected mismatched customers when an OrganizationBilling row already existed. Now if no row exists (edge case — subscribeToPentestPlan always creates one first), verify the Stripe customer's metadata.organizationId matches before accepting the session. - Fix Stripe return URLs to always be absolute: derive origin from NEXT_PUBLIC_BETTER_AUTH_URL with a request-header fallback so Stripe never receives a relative URL when the env var is unset. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(billing): deduplicate concurrent overage charges + preserve manual repo URL input - Add Stripe idempotency key to paymentIntents.create scoped to orgId + period start + run number; concurrent creates at the quota boundary now deduplicate to a single charge instead of double-billing - Show manual URL input alongside the GitHub repo selector so users with more than 100 repos (beyond the first-page fetch) can still paste a repo URL directly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(billing): use per-run ID for overage charge idempotency key The previous key used `runsThisPeriod` (an aggregate count), meaning two concurrent creates could observe the same count and share the same key — Stripe would deduplicate to a single PaymentIntent even when two separate overage runs were billed. Replace with `pentest-overage-{orgId}-{runId}` so each run gets a globally unique idempotency key, eliminating the underbilling race. Also include Codex-added tests for the GitHub connection UI flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent adc8644 commit 9ec0449

25 files changed

Lines changed: 1172 additions & 510 deletions

apps/api/src/security-penetration-tests/dto/create-penetration-test.dto.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,6 @@ export class CreatePenetrationTestDto {
5151
@IsString()
5252
workspace?: string;
5353

54-
@ApiPropertyOptional({
55-
description:
56-
'Set false to reject non-mocked checkout flows for strict behavior',
57-
required: false,
58-
default: true,
59-
})
60-
@IsOptional()
61-
@IsBoolean()
62-
mockCheckout?: boolean;
63-
6454
@ApiPropertyOptional({
6555
description: 'Optional webhook URL to notify when report generation completes',
6656
required: false,

apps/api/src/security-penetration-tests/security-penetration-tests.controller.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ export class SecurityPenetrationTestsController {
7676
return this.service.createReport(organizationId, body);
7777
}
7878

79+
@Get('github/repos')
80+
@UseGuards(HybridAuthGuard)
81+
@ApiOperation({
82+
summary: 'List accessible GitHub repositories',
83+
description:
84+
'Returns GitHub repositories accessible with the connected GitHub integration.',
85+
})
86+
@ApiResponse({
87+
status: 200,
88+
description: 'Repository list returned',
89+
})
90+
async listGithubRepos(@OrganizationId() organizationId: string) {
91+
return this.service.listGithubRepos(organizationId);
92+
}
93+
7994
@Get(':id')
8095
@UseGuards(HybridAuthGuard)
8196
@ApiOperation({

apps/api/src/security-penetration-tests/security-penetration-tests.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Module } from '@nestjs/common';
22
import { AuthModule } from '../auth/auth.module';
3+
import { IntegrationPlatformModule } from '../integration-platform/integration-platform.module';
34
import { SecurityPenetrationTestsController } from './security-penetration-tests.controller';
45
import { SecurityPenetrationTestsService } from './security-penetration-tests.service';
56

67
@Module({
7-
imports: [AuthModule],
8+
imports: [AuthModule, IntegrationPlatformModule],
89
controllers: [SecurityPenetrationTestsController],
910
providers: [SecurityPenetrationTestsService],
1011
})

apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { HttpException, HttpStatus } from '@nestjs/common';
22
import { db } from '@trycompai/db';
33
import { createHash } from 'node:crypto';
4+
import type { CredentialVaultService } from '../integration-platform/services/credential-vault.service';
45
import type { CreatePenetrationTestDto } from './dto/create-penetration-test.dto';
56
import { SecurityPenetrationTestsService } from './security-penetration-tests.service';
67

8+
const mockCredentialVaultService: jest.Mocked<Pick<CredentialVaultService, 'getDecryptedCredentials'>> = {
9+
getDecryptedCredentials: jest.fn(),
10+
};
11+
712
jest.mock('@trycompai/db', () => ({
813
db: {
914
securityPenetrationTestRun: {
@@ -16,6 +21,12 @@ jest.mock('@trycompai/db', () => ({
1621
findUnique: jest.fn(),
1722
update: jest.fn(),
1823
},
24+
integrationProvider: {
25+
findUnique: jest.fn(),
26+
},
27+
integrationConnection: {
28+
findFirst: jest.fn(),
29+
},
1930
},
2031
}));
2132

@@ -30,6 +41,12 @@ type MockDb = {
3041
findUnique: jest.Mock;
3142
update: jest.Mock;
3243
};
44+
integrationProvider: {
45+
findUnique: jest.Mock;
46+
};
47+
integrationConnection: {
48+
findFirst: jest.Mock;
49+
};
3350
};
3451

3552
describe('SecurityPenetrationTestsService', () => {
@@ -58,7 +75,9 @@ describe('SecurityPenetrationTestsService', () => {
5875

5976
beforeEach(() => {
6077
process.env.MACED_API_KEY = 'test-maced-api-key';
61-
service = new SecurityPenetrationTestsService();
78+
service = new SecurityPenetrationTestsService(
79+
mockCredentialVaultService as unknown as CredentialVaultService,
80+
);
6281
fetchMock.mockReset();
6382
global.fetch = fetchMock as unknown as typeof fetch;
6483
mockedDb.securityPenetrationTestRun.upsert.mockResolvedValue({});
@@ -77,6 +96,10 @@ describe('SecurityPenetrationTestsService', () => {
7796
}),
7897
});
7998
mockedDb.secret.update.mockResolvedValue({});
99+
// Default: no GitHub integration connected — getGithubTokenForOrg returns null
100+
mockedDb.integrationProvider.findUnique.mockResolvedValue(null);
101+
mockedDb.integrationConnection.findFirst.mockResolvedValue(null);
102+
mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue(null);
80103
jest.clearAllMocks();
81104
});
82105

apps/api/src/security-penetration-tests/security-penetration-tests.service.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { db } from '@trycompai/db';
1010
import { createHash, timingSafeEqual } from 'node:crypto';
1111

12+
import { CredentialVaultService } from '../integration-platform/services/credential-vault.service';
1213
import type { CreatePenetrationTestDto } from './dto/create-penetration-test.dto';
1314
import {
1415
MacedClient,
@@ -17,6 +18,14 @@ import {
1718
type MacedPentestRun,
1819
} from './maced-client';
1920

21+
export interface GithubRepo {
22+
id: number;
23+
name: string;
24+
fullName: string;
25+
private: boolean;
26+
htmlUrl: string;
27+
}
28+
2029
export type PentestReportStatus =
2130
| 'provisioning'
2231
| 'cloning'
@@ -84,6 +93,9 @@ interface PersistedWebhookHandshake {
8493
export class SecurityPenetrationTestsService {
8594
private readonly logger = new Logger(SecurityPenetrationTestsService.name);
8695
private readonly macedClient = new MacedClient();
96+
97+
constructor(private readonly credentialVaultService: CredentialVaultService) {}
98+
8799
private readonly canonicalWebhookPath = '/v1/security-penetration-tests/webhook';
88100
private readonly defaultWebhookBaseUrl = 'https://api.trycomp.ai';
89101
private readonly defaultCompWebhookHosts = new Set([
@@ -118,7 +130,16 @@ export class SecurityPenetrationTestsService {
118130
): Promise<SecurityPenetrationTest> {
119131
const resolvedWebhookUrl = this.resolveWebhookUrl(payload.webhookUrl);
120132

121-
const sanitizedPayload = {
133+
const sanitizedPayload: {
134+
targetUrl: string;
135+
repoUrl?: string;
136+
githubToken?: string;
137+
configYaml?: string;
138+
pipelineTesting?: boolean;
139+
testMode?: boolean;
140+
workspace?: string;
141+
webhookUrl?: string;
142+
} = {
122143
targetUrl: payload.targetUrl,
123144
repoUrl: payload.repoUrl,
124145
githubToken: payload.githubToken,
@@ -129,6 +150,14 @@ export class SecurityPenetrationTestsService {
129150
webhookUrl: resolvedWebhookUrl,
130151
};
131152

153+
if (
154+
payload.repoUrl?.startsWith('https://github.com/') &&
155+
!sanitizedPayload.githubToken
156+
) {
157+
sanitizedPayload.githubToken =
158+
(await this.getGithubTokenForOrg(organizationId)) ?? undefined;
159+
}
160+
132161
const createdReport = await this.macedClient.createPentest(sanitizedPayload);
133162

134163
const providerRunId = createdReport.id;
@@ -193,6 +222,51 @@ export class SecurityPenetrationTestsService {
193222
return this.mapMacedRunToSecurityPenetrationTest(createdReport);
194223
}
195224

225+
async listGithubRepos(
226+
organizationId: string,
227+
): Promise<{ repos: GithubRepo[]; connected: boolean }> {
228+
const token = await this.getGithubTokenForOrg(organizationId);
229+
if (!token) {
230+
return { repos: [], connected: false };
231+
}
232+
233+
try {
234+
const response = await fetch(
235+
'https://api.github.com/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member',
236+
{
237+
headers: {
238+
Authorization: `Bearer ${token}`,
239+
Accept: 'application/vnd.github+json',
240+
},
241+
},
242+
);
243+
244+
if (!response.ok) {
245+
this.logger.warn(
246+
`GitHub repos API returned ${response.status} for org=${organizationId}`,
247+
);
248+
return { repos: [], connected: true };
249+
}
250+
251+
const raw = (await response.json()) as Array<Record<string, unknown>>;
252+
const repos: GithubRepo[] = raw.map((r) => ({
253+
id: r.id as number,
254+
name: r.name as string,
255+
fullName: r.full_name as string,
256+
private: r.private as boolean,
257+
htmlUrl: r.html_url as string,
258+
}));
259+
260+
return { repos, connected: true };
261+
} catch (error) {
262+
this.logger.error(
263+
`Failed to fetch GitHub repos for org=${organizationId}`,
264+
error instanceof Error ? error.message : String(error),
265+
);
266+
return { repos: [], connected: true };
267+
}
268+
}
269+
196270
async getReport(organizationId: string, id: string): Promise<SecurityPenetrationTest> {
197271
await this.assertRunOwnership(organizationId, id);
198272
const report = await this.macedClient.getPentest(id);
@@ -320,6 +394,45 @@ export class SecurityPenetrationTestsService {
320394
};
321395
}
322396

397+
private async getGithubTokenForOrg(organizationId: string): Promise<string | null> {
398+
try {
399+
const provider = await db.integrationProvider.findUnique({
400+
where: { slug: 'github' },
401+
select: { id: true },
402+
});
403+
404+
if (!provider) {
405+
return null;
406+
}
407+
408+
const connection = await db.integrationConnection.findFirst({
409+
where: {
410+
providerId: provider.id,
411+
organizationId,
412+
status: 'active',
413+
},
414+
select: { id: true },
415+
});
416+
417+
if (!connection) {
418+
return null;
419+
}
420+
421+
const credentials = await this.credentialVaultService.getDecryptedCredentials(
422+
connection.id,
423+
);
424+
425+
const token = credentials?.access_token;
426+
return typeof token === 'string' && token.length > 0 ? token : null;
427+
} catch (error) {
428+
this.logger.warn(
429+
`Could not retrieve GitHub token for org=${organizationId}`,
430+
error instanceof Error ? error.message : String(error),
431+
);
432+
return null;
433+
}
434+
}
435+
323436
private trimTrailingSlashes(value: string): string {
324437
let end = value.length;
325438
while (end > 1 && value.charCodeAt(end - 1) === 47) {

apps/app/.env.example

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,12 @@ NOVU_API_KEY=
6363
NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER=
6464

6565
# Internal API Authentication
66-
INTERNAL_API_TOKEN= # Shared secret for internal API calls (must match API's)
66+
INTERNAL_API_TOKEN= # Shared secret for internal API calls (must match API's)
67+
68+
# Stripe
69+
STRIPE_SECRET_KEY= # Stripe secret key (sk_live_... or sk_test_...)
70+
71+
# Pentest Subscription Billing
72+
STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID= # Monthly subscription price ID (price_...)
73+
STRIPE_PENTEST_OVERAGE_PRICE_ID= # Per-run overage price ID (price_...)
74+
STRIPE_PENTEST_WEBHOOK_SECRET= # Webhook signing secret (whsec_...) from Stripe dashboard or CLI

apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ function AppShellWrapperContent({
282282
}
283283
/>
284284
{isSettingsActive ? (
285-
<SettingsSidebar orgId={organization.id} showBrowserTab={isWebAutomationsEnabled} />
285+
<SettingsSidebar orgId={organization.id} showBrowserTab={isWebAutomationsEnabled} showBillingTab={isSecurityEnabled} />
286286
) : isTrustActive ? (
287287
<TrustSidebar orgId={organization.id} />
288288
) : isSecurityActive && isSecurityEnabled ? (

apps/app/src/app/(app)/[orgId]/security/components/SecuritySidebar.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,18 @@ interface SecuritySidebarProps {
88
orgId: string;
99
}
1010

11-
type SecurityNavItem = {
12-
id: string;
13-
label: string;
14-
path: string;
15-
};
16-
1711
export function SecuritySidebar({ orgId }: SecuritySidebarProps) {
1812
const pathname = usePathname() ?? '';
1913

20-
const items: SecurityNavItem[] = [
14+
const items = [
2115
{
2216
id: 'penetration-tests',
2317
label: 'Penetration Tests',
2418
path: `/${orgId}/security/penetration-tests`,
2519
},
2620
];
2721

28-
const isPathActive = (path: string) => {
29-
return pathname.startsWith(path);
30-
};
22+
const isPathActive = (path: string) => pathname.startsWith(path);
3123

3224
return (
3325
<AppShellNav>

0 commit comments

Comments
 (0)