Skip to content
Merged
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
36 changes: 0 additions & 36 deletions .github/workflows/maced-contract-canary.yml

This file was deleted.

5 changes: 4 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@aws-sdk/s3-request-presigner": "3.1013.0",
"@browserbasehq/sdk": "2.6.0",
"@browserbasehq/stagehand": "^3.2.1",
"@maced/api-client": "^0.9.1",
"@mendable/firecrawl-js": "^4.9.3",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
Expand Down Expand Up @@ -157,6 +158,9 @@
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(@maced/api-client|better-auth)/)"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
Expand Down Expand Up @@ -197,7 +201,6 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:e2e:maced": "MACED_CONTRACT_E2E=1 jest --config ./test/jest-e2e.json --runInBand ./maced-contract.e2e-spec.ts",
"test:watch": "jest --watch",
"typecheck": "tsc --noEmit"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const SEGMENT_TO_RESOURCE: Record<
tasks: { entity: AuditLogEntityType.task, singular: 'task' },
vendors: { entity: AuditLogEntityType.vendor, singular: 'vendor' },
context: { entity: AuditLogEntityType.organization, singular: 'context' },
'pentest-credits': {
entity: AuditLogEntityType.pentest,
singular: 'pentest credits',
},
};

const SPECIAL_ACTION_DESCRIPTIONS: Record<string, string> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EvidenceFormsModule } from '../evidence-forms/evidence-forms.module';
import { PoliciesModule } from '../policies/policies.module';
import { CommentsModule } from '../comments/comments.module';
import { AttachmentsModule } from '../attachments/attachments.module';
import { SecurityPenetrationTestsModule } from '../security-penetration-tests/security-penetration-tests.module';
import { AdminOrganizationsController } from './admin-organizations.controller';
import { AdminOrganizationsService } from './admin-organizations.service';
import { PurgeOrganizationService } from './purge-organization.service';
Expand All @@ -18,6 +19,7 @@ import { AdminTasksController } from './admin-tasks.controller';
import { AdminVendorsController } from './admin-vendors.controller';
import { AdminContextController } from './admin-context.controller';
import { AdminEvidenceController } from './admin-evidence.controller';
import { AdminPentestCreditsController } from './admin-pentest-credits.controller';

@Module({
imports: [
Expand All @@ -29,6 +31,7 @@ import { AdminEvidenceController } from './admin-evidence.controller';
PoliciesModule,
CommentsModule,
AttachmentsModule,
SecurityPenetrationTestsModule,
],
controllers: [
AdminOrganizationsController,
Expand All @@ -38,6 +41,7 @@ import { AdminEvidenceController } from './admin-evidence.controller';
AdminVendorsController,
AdminContextController,
AdminEvidenceController,
AdminPentestCreditsController,
],
providers: [
AdminOrganizationsService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { PlatformAdminGuard } from '../auth/platform-admin.guard';
import { PentestCreditsService } from '../security-penetration-tests/pentest-credits.service';
import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor';

/**
* Request body for granting credits via the admin panel. `amount` is capped
* at 1000 to prevent typo-induced runaway grants — admins can submit
* multiple times if a larger pool is genuinely needed.
*/
class GrantPentestCreditsDto {
@IsInt()
@Min(1)
@Max(1000)
amount!: number;

/**
* Free-form note. Persisted on the audit log entry as `data.note` so
* support / compliance can reconstruct *why* a grant happened.
*/
@IsOptional()
@IsString()
note?: string;
}

@ApiExcludeController()
@ApiTags('Admin - Pentest Credits')
@Controller({ path: 'admin/organizations', version: '1' })
@UseGuards(PlatformAdminGuard)
@UseInterceptors(AdminAuditLogInterceptor)
@Throttle({ default: { ttl: 60_000, limit: 30 } })
export class AdminPentestCreditsController {
constructor(private readonly credits: PentestCreditsService) {}

@Get(':orgId/pentest-credits')
@ApiOperation({
summary: 'Get pentest credit wallet status for any organization',
})
async getStatus(@Param('orgId') orgId: string) {
return this.credits.getStatus(orgId);
}

// POST /:orgId/pentest-credits (no `/grant` suffix). The
// AdminAuditLogInterceptor's URL parser treats the segment after the
// resource as an entity id; if we used `/grant`, the audit log
// would record `entityId: "grant"` which is meaningless and breaks
// the admin audit trail. Keeping the route shape standard
// (`:orgId/<resource>`) lets the interceptor produce correct metadata.
@Post(':orgId/pentest-credits')
@ApiOperation({
summary: 'Grant pentest credits to an organization (platform admin)',
})
async grant(
@Param('orgId') orgId: string,
@Body() body: GrantPentestCreditsDto,
) {
if (!Number.isInteger(body.amount) || body.amount < 1) {
throw new BadRequestException('amount must be a positive integer');
}
await this.credits.grant(orgId, body.amount, 'manual');
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return this.credits.getStatus(orgId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ export class PurgeOrganizationSnapshotService {
}

const [
billing,
pentest,
trustResources,
trustNdas,
trustDocs,
Expand All @@ -36,14 +34,13 @@ export class PurgeOrganizationSnapshotService {
integrations,
counts,
] = await Promise.all([
db.organizationBilling.findUnique({
where: { organizationId },
select: { stripeCustomerId: true },
}),
db.pentestSubscription.findUnique({
where: { organizationId },
select: { stripeSubscriptionId: true },
}),
// The legacy `organization_billing` and `pentest_subscriptions`
// tables were dropped in migration 20260427000000_pentest_credits;
// they were Stripe-coupled records that never had production data
// and have been superseded by the `pentest_credits` wallet model.
// The snapshot intentionally omits them — there's nothing to
// capture. If/when v2 introduces real Stripe billing, the new
// tables get added here at that point.
db.trustResource.findMany({
where: { organizationId },
select: { s3Key: true },
Expand Down Expand Up @@ -130,9 +127,13 @@ export class PurgeOrganizationSnapshotService {
return {
organization: { id: org.id, name: org.name, slug: org.slug },
counts,
// Stripe IDs intentionally null — the source tables were dropped
// in 20260427000000_pentest_credits. The shape is preserved so
// downstream consumers (purge orchestrator) don't need to change
// until v2 billing replaces these.
stripe: {
customerId: billing?.stripeCustomerId ?? null,
subscriptionId: pentest?.stripeSubscriptionId ?? null,
customerId: null,
subscriptionId: null,
},
s3KeysByBucket,
knowledgeBaseDocumentIds: kbDocs.map((d) => d.id),
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/audit/audit-log.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const RESOURCE_TO_ENTITY_TYPE: Record<
trust: AuditLogEntityType.trust,
app: AuditLogEntityType.organization,
questionnaire: AuditLogEntityType.organization,
pentest: AuditLogEntityType.pentest,
audit: null,
};

Expand Down
26 changes: 25 additions & 1 deletion apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import { adminAuthRateLimiter } from './auth/admin-rate-limit.middleware';
import { originCheckMiddleware } from './auth/origin-check.middleware';
import { mkdirSync, writeFileSync, existsSync } from 'fs';

declare module 'express-serve-static-core' {
interface Request {
rawBody?: Buffer;
}
}

let app: INestApplication | null = null;

function describeServer(baseUrl: string): string {
Expand Down Expand Up @@ -78,6 +84,23 @@ async function bootstrap(): Promise<void> {
// request stream to properly read the body (including OAuth callbackURL).
// Express-level middleware runs BEFORE NestJS module middleware, so without this
// skip, express.json() would consume the stream before better-auth's handler.
// Routes that need the exact request bytes for HMAC signature verification.
// Anything matched here gets `req.rawBody` populated; everything else uses
// the standard parser which discards the buffer to avoid keeping a 150MB
// copy of every JSON payload alive on the heap.
const RAW_BODY_PATHS = [
'/v1/security-penetration-tests/webhook',
'/security-penetration-tests/webhook',
];
const needsRawBody = (req: express.Request): boolean =>
RAW_BODY_PATHS.some((p) => req.path.endsWith(p));

const jsonParserWithRaw = express.json({
limit: '150mb',
verify: (req, _res, buf) => {
(req as express.Request).rawBody = buf;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
},
});
const jsonParser = express.json({ limit: '150mb' });
const urlencodedParser = express.urlencoded({
limit: '150mb',
Expand All @@ -92,7 +115,8 @@ async function bootstrap(): Promise<void> {
if (req.path.startsWith('/api/auth')) {
return next();
}
jsonParser(req, res, (err?: unknown) => {
const parser = needsRawBody(req) ? jsonParserWithRaw : jsonParser;
parser(req, res, (err?: unknown) => {
if (err) return next(err);
urlencodedParser(req, res, next);
});
Expand Down
51 changes: 0 additions & 51 deletions apps/api/src/security-penetration-tests/README.md

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,6 @@ export class CreatePenetrationTestDto {
@IsUrl()
repoUrl?: string;

@ApiPropertyOptional({
description: 'GitHub token used for cloning private repositories',
required: false,
})
@IsOptional()
@IsString()
githubToken?: string;

@ApiPropertyOptional({
description: 'Optional YAML configuration for the pentest run',
required: false,
})
@IsOptional()
@IsString()
configYaml?: string;

@ApiPropertyOptional({
description: 'Whether to enable pipeline testing mode',
required: false,
Expand All @@ -43,14 +27,6 @@ export class CreatePenetrationTestDto {
@IsBoolean()
pipelineTesting?: boolean;

@ApiPropertyOptional({
description: 'Workspace identifier used by the pentest engine',
required: false,
})
@IsOptional()
@IsString()
workspace?: string;

@ApiPropertyOptional({
description:
'Optional webhook URL to notify when report generation completes',
Expand Down
Loading
Loading