Skip to content

Commit 9a65c3b

Browse files
authored
Merge pull request #2691 from trycompai/feat/pentest-credits
feat(pentest): credits wallet, admin grants, audit logging, UX polish
2 parents dbffafb + f7a9ce9 commit 9a65c3b

72 files changed

Lines changed: 4980 additions & 3505 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/maced-contract-canary.yml

Lines changed: 0 additions & 36 deletions
This file was deleted.

apps/api/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"@aws-sdk/s3-request-presigner": "3.1013.0",
6262
"@browserbasehq/sdk": "2.6.0",
6363
"@browserbasehq/stagehand": "^3.2.1",
64+
"@maced/api-client": "^0.9.1",
6465
"@mendable/firecrawl-js": "^4.9.3",
6566
"@nestjs/common": "^11.0.1",
6667
"@nestjs/config": "^4.0.2",
@@ -157,6 +158,9 @@
157158
"transform": {
158159
"^.+\\.(t|j)sx?$": "ts-jest"
159160
},
161+
"transformIgnorePatterns": [
162+
"node_modules/(?!(@maced/api-client|better-auth)/)"
163+
],
160164
"collectCoverageFrom": [
161165
"**/*.(t|j)s"
162166
],
@@ -197,7 +201,6 @@
197201
"test:cov": "jest --coverage",
198202
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
199203
"test:e2e": "jest --config ./test/jest-e2e.json",
200-
"test:e2e:maced": "MACED_CONTRACT_E2E=1 jest --config ./test/jest-e2e.json --runInBand ./maced-contract.e2e-spec.ts",
201204
"test:watch": "jest --watch",
202205
"typecheck": "tsc --noEmit"
203206
}

apps/api/src/admin-organizations/admin-audit-log.interceptor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const SEGMENT_TO_RESOURCE: Record<
2020
tasks: { entity: AuditLogEntityType.task, singular: 'task' },
2121
vendors: { entity: AuditLogEntityType.vendor, singular: 'vendor' },
2222
context: { entity: AuditLogEntityType.organization, singular: 'context' },
23+
'pentest-credits': {
24+
entity: AuditLogEntityType.pentest,
25+
singular: 'pentest credits',
26+
},
2327
};
2428

2529
const SPECIAL_ACTION_DESCRIPTIONS: Record<string, string> = {

apps/api/src/admin-organizations/admin-organizations.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { EvidenceFormsModule } from '../evidence-forms/evidence-forms.module';
77
import { PoliciesModule } from '../policies/policies.module';
88
import { CommentsModule } from '../comments/comments.module';
99
import { AttachmentsModule } from '../attachments/attachments.module';
10+
import { SecurityPenetrationTestsModule } from '../security-penetration-tests/security-penetration-tests.module';
1011
import { AdminOrganizationsController } from './admin-organizations.controller';
1112
import { AdminOrganizationsService } from './admin-organizations.service';
1213
import { PurgeOrganizationService } from './purge-organization.service';
@@ -18,6 +19,7 @@ import { AdminTasksController } from './admin-tasks.controller';
1819
import { AdminVendorsController } from './admin-vendors.controller';
1920
import { AdminContextController } from './admin-context.controller';
2021
import { AdminEvidenceController } from './admin-evidence.controller';
22+
import { AdminPentestCreditsController } from './admin-pentest-credits.controller';
2123

2224
@Module({
2325
imports: [
@@ -29,6 +31,7 @@ import { AdminEvidenceController } from './admin-evidence.controller';
2931
PoliciesModule,
3032
CommentsModule,
3133
AttachmentsModule,
34+
SecurityPenetrationTestsModule,
3235
],
3336
controllers: [
3437
AdminOrganizationsController,
@@ -38,6 +41,7 @@ import { AdminEvidenceController } from './admin-evidence.controller';
3841
AdminVendorsController,
3942
AdminContextController,
4043
AdminEvidenceController,
44+
AdminPentestCreditsController,
4145
],
4246
providers: [
4347
AdminOrganizationsService,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {
2+
BadRequestException,
3+
Body,
4+
Controller,
5+
Get,
6+
Param,
7+
Post,
8+
UseGuards,
9+
UseInterceptors,
10+
} from '@nestjs/common';
11+
import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
12+
import { Throttle } from '@nestjs/throttler';
13+
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
14+
import { PlatformAdminGuard } from '../auth/platform-admin.guard';
15+
import { PentestCreditsService } from '../security-penetration-tests/pentest-credits.service';
16+
import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor';
17+
18+
/**
19+
* Request body for granting credits via the admin panel. `amount` is capped
20+
* at 1000 to prevent typo-induced runaway grants — admins can submit
21+
* multiple times if a larger pool is genuinely needed.
22+
*/
23+
class GrantPentestCreditsDto {
24+
@IsInt()
25+
@Min(1)
26+
@Max(1000)
27+
amount!: number;
28+
29+
/**
30+
* Free-form note. Persisted on the audit log entry as `data.note` so
31+
* support / compliance can reconstruct *why* a grant happened.
32+
*/
33+
@IsOptional()
34+
@IsString()
35+
note?: string;
36+
}
37+
38+
@ApiExcludeController()
39+
@ApiTags('Admin - Pentest Credits')
40+
@Controller({ path: 'admin/organizations', version: '1' })
41+
@UseGuards(PlatformAdminGuard)
42+
@UseInterceptors(AdminAuditLogInterceptor)
43+
@Throttle({ default: { ttl: 60_000, limit: 30 } })
44+
export class AdminPentestCreditsController {
45+
constructor(private readonly credits: PentestCreditsService) {}
46+
47+
@Get(':orgId/pentest-credits')
48+
@ApiOperation({
49+
summary: 'Get pentest credit wallet status for any organization',
50+
})
51+
async getStatus(@Param('orgId') orgId: string) {
52+
return this.credits.getStatus(orgId);
53+
}
54+
55+
// POST /:orgId/pentest-credits (no `/grant` suffix). The
56+
// AdminAuditLogInterceptor's URL parser treats the segment after the
57+
// resource as an entity id; if we used `/grant`, the audit log
58+
// would record `entityId: "grant"` which is meaningless and breaks
59+
// the admin audit trail. Keeping the route shape standard
60+
// (`:orgId/<resource>`) lets the interceptor produce correct metadata.
61+
@Post(':orgId/pentest-credits')
62+
@ApiOperation({
63+
summary: 'Grant pentest credits to an organization (platform admin)',
64+
})
65+
async grant(
66+
@Param('orgId') orgId: string,
67+
@Body() body: GrantPentestCreditsDto,
68+
) {
69+
if (!Number.isInteger(body.amount) || body.amount < 1) {
70+
throw new BadRequestException('amount must be a positive integer');
71+
}
72+
await this.credits.grant(orgId, body.amount, 'manual');
73+
return this.credits.getStatus(orgId);
74+
}
75+
}

apps/api/src/admin-organizations/purge-organization-snapshot.service.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ export class PurgeOrganizationSnapshotService {
2222
}
2323

2424
const [
25-
billing,
26-
pentest,
2725
trustResources,
2826
trustNdas,
2927
trustDocs,
@@ -36,14 +34,13 @@ export class PurgeOrganizationSnapshotService {
3634
integrations,
3735
counts,
3836
] = await Promise.all([
39-
db.organizationBilling.findUnique({
40-
where: { organizationId },
41-
select: { stripeCustomerId: true },
42-
}),
43-
db.pentestSubscription.findUnique({
44-
where: { organizationId },
45-
select: { stripeSubscriptionId: true },
46-
}),
37+
// The legacy `organization_billing` and `pentest_subscriptions`
38+
// tables were dropped in migration 20260427000000_pentest_credits;
39+
// they were Stripe-coupled records that never had production data
40+
// and have been superseded by the `pentest_credits` wallet model.
41+
// The snapshot intentionally omits them — there's nothing to
42+
// capture. If/when v2 introduces real Stripe billing, the new
43+
// tables get added here at that point.
4744
db.trustResource.findMany({
4845
where: { organizationId },
4946
select: { s3Key: true },
@@ -130,9 +127,13 @@ export class PurgeOrganizationSnapshotService {
130127
return {
131128
organization: { id: org.id, name: org.name, slug: org.slug },
132129
counts,
130+
// Stripe IDs intentionally null — the source tables were dropped
131+
// in 20260427000000_pentest_credits. The shape is preserved so
132+
// downstream consumers (purge orchestrator) don't need to change
133+
// until v2 billing replaces these.
133134
stripe: {
134-
customerId: billing?.stripeCustomerId ?? null,
135-
subscriptionId: pentest?.stripeSubscriptionId ?? null,
135+
customerId: null,
136+
subscriptionId: null,
136137
},
137138
s3KeysByBucket,
138139
knowledgeBaseDocumentIds: kbDocs.map((d) => d.id),

apps/api/src/audit/audit-log.constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const RESOURCE_TO_ENTITY_TYPE: Record<
3939
trust: AuditLogEntityType.trust,
4040
app: AuditLogEntityType.organization,
4141
questionnaire: AuditLogEntityType.organization,
42+
pentest: AuditLogEntityType.pentest,
4243
audit: null,
4344
};
4445

apps/api/src/main.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import { adminAuthRateLimiter } from './auth/admin-rate-limit.middleware';
1313
import { originCheckMiddleware } from './auth/origin-check.middleware';
1414
import { mkdirSync, writeFileSync, existsSync } from 'fs';
1515

16+
declare module 'express-serve-static-core' {
17+
interface Request {
18+
rawBody?: Buffer;
19+
}
20+
}
21+
1622
let app: INestApplication | null = null;
1723

1824
function describeServer(baseUrl: string): string {
@@ -78,6 +84,23 @@ async function bootstrap(): Promise<void> {
7884
// request stream to properly read the body (including OAuth callbackURL).
7985
// Express-level middleware runs BEFORE NestJS module middleware, so without this
8086
// skip, express.json() would consume the stream before better-auth's handler.
87+
// Routes that need the exact request bytes for HMAC signature verification.
88+
// Anything matched here gets `req.rawBody` populated; everything else uses
89+
// the standard parser which discards the buffer to avoid keeping a 150MB
90+
// copy of every JSON payload alive on the heap.
91+
const RAW_BODY_PATHS = [
92+
'/v1/security-penetration-tests/webhook',
93+
'/security-penetration-tests/webhook',
94+
];
95+
const needsRawBody = (req: express.Request): boolean =>
96+
RAW_BODY_PATHS.some((p) => req.path.endsWith(p));
97+
98+
const jsonParserWithRaw = express.json({
99+
limit: '150mb',
100+
verify: (req, _res, buf) => {
101+
(req as express.Request).rawBody = buf;
102+
},
103+
});
81104
const jsonParser = express.json({ limit: '150mb' });
82105
const urlencodedParser = express.urlencoded({
83106
limit: '150mb',
@@ -92,7 +115,8 @@ async function bootstrap(): Promise<void> {
92115
if (req.path.startsWith('/api/auth')) {
93116
return next();
94117
}
95-
jsonParser(req, res, (err?: unknown) => {
118+
const parser = needsRawBody(req) ? jsonParserWithRaw : jsonParser;
119+
parser(req, res, (err?: unknown) => {
96120
if (err) return next(err);
97121
urlencodedParser(req, res, next);
98122
});

apps/api/src/security-penetration-tests/README.md

Lines changed: 0 additions & 51 deletions
This file was deleted.

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

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,6 @@ export class CreatePenetrationTestDto {
1818
@IsUrl()
1919
repoUrl?: string;
2020

21-
@ApiPropertyOptional({
22-
description: 'GitHub token used for cloning private repositories',
23-
required: false,
24-
})
25-
@IsOptional()
26-
@IsString()
27-
githubToken?: string;
28-
29-
@ApiPropertyOptional({
30-
description: 'Optional YAML configuration for the pentest run',
31-
required: false,
32-
})
33-
@IsOptional()
34-
@IsString()
35-
configYaml?: string;
36-
3721
@ApiPropertyOptional({
3822
description: 'Whether to enable pipeline testing mode',
3923
required: false,
@@ -43,14 +27,6 @@ export class CreatePenetrationTestDto {
4327
@IsBoolean()
4428
pipelineTesting?: boolean;
4529

46-
@ApiPropertyOptional({
47-
description: 'Workspace identifier used by the pentest engine',
48-
required: false,
49-
})
50-
@IsOptional()
51-
@IsString()
52-
workspace?: string;
53-
5430
@ApiPropertyOptional({
5531
description:
5632
'Optional webhook URL to notify when report generation completes',

0 commit comments

Comments
 (0)