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 apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ APP_AWS_ORG_ASSETS_BUCKET=

DATABASE_URL=

NOVU_API_KEY=
INTERNAL_API_TOKEN=

# Upstash
UPSTASH_REDIS_REST_URL=
Expand Down
5 changes: 4 additions & 1 deletion apps/api/Dockerfile.multistage
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ COPY packages/db/package.json ./packages/db/
COPY packages/utils/package.json ./packages/utils/
COPY packages/integration-platform/package.json ./packages/integration-platform/
COPY packages/tsconfig/package.json ./packages/tsconfig/
COPY packages/email/package.json ./packages/email/

# Copy API package.json
COPY apps/api/package.json ./apps/api/
Expand All @@ -32,6 +33,7 @@ COPY packages/db ./packages/db
COPY packages/utils ./packages/utils
COPY packages/integration-platform ./packages/integration-platform
COPY packages/tsconfig ./packages/tsconfig
COPY packages/email ./packages/email

# Copy API source
COPY apps/api ./apps/api
Expand All @@ -42,6 +44,7 @@ COPY --from=deps /app/node_modules ./node_modules
# Build workspace packages
RUN cd packages/db && bun run build && cd ../..
RUN cd packages/integration-platform && bun run build && cd ../..
RUN cd packages/email && bun run build && cd ../..

# Generate Prisma client for API (copy schema and generate)
RUN cd packages/db && node scripts/combine-schemas.js && cd ../..
Expand Down Expand Up @@ -75,6 +78,7 @@ COPY --from=builder /app/packages/db ./packages/db
COPY --from=builder /app/packages/utils ./packages/utils
COPY --from=builder /app/packages/integration-platform ./packages/integration-platform
COPY --from=builder /app/packages/tsconfig ./packages/tsconfig
COPY --from=builder /app/packages/email ./packages/email

# Copy production node_modules (includes symlinks to workspace packages above)
COPY --from=builder /app/node_modules ./node_modules
Expand All @@ -101,4 +105,3 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \

# Start the application
CMD ["node", "dist/src/main.js"]

4 changes: 3 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@browserbasehq/sdk": "^2.6.0",
"@browserbasehq/stagehand": "^3.0.5",
"@comp/integration-platform": "workspace:*",
"@mendable/firecrawl-js": "^4.9.3",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
Expand All @@ -25,7 +26,8 @@
"@react-email/components": "^0.0.41",
"@trigger.dev/build": "4.0.6",
"@trigger.dev/sdk": "4.0.6",
"@trycompai/db": "1.3.19",
"@trycompai/db": "^1.3.20-canary.0",
"@trycompai/email": "workspace:*",
"@upstash/vector": "^1.2.2",
"adm-zip": "^0.5.16",
"ai": "^5.0.60",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { SOAModule } from './soa/soa.module';
import { IntegrationPlatformModule } from './integration-platform/integration-platform.module';
import { CloudSecurityModule } from './cloud-security/cloud-security.module';
import { BrowserbaseModule } from './browserbase/browserbase.module';
import { TaskManagementModule } from './task-management/task-management.module';

@Module({
imports: [
Expand Down Expand Up @@ -68,6 +69,7 @@ import { BrowserbaseModule } from './browserbase/browserbase.module';
IntegrationPlatformModule,
CloudSecurityModule,
BrowserbaseModule,
TaskManagementModule,
],
controllers: [AppController],
providers: [
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/app/s3.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { GetObjectCommand, S3Client, type GetObjectCommandOutput } from '@aws-sdk/client-s3';
import {
GetObjectCommand,
S3Client,
type GetObjectCommandOutput,
} from '@aws-sdk/client-s3';
import { Logger } from '@nestjs/common';
import '../config/load-env';

Expand Down
17 changes: 15 additions & 2 deletions apps/api/src/attachments/attachments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { UploadAttachmentDto } from './upload-attachment.dto';
export class AttachmentsService {
private s3Client: S3Client;
private bucketName: string;
private readonly MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
private readonly MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; // 100MB
private readonly SIGNED_URL_EXPIRY = 900; // 15 minutes

constructor() {
Expand Down Expand Up @@ -129,7 +129,20 @@ export class AttachmentsService {
const fileId = randomBytes(16).toString('hex');
const sanitizedFileName = this.sanitizeFileName(uploadDto.fileName);
const timestamp = Date.now();
const s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`;

// Special S3 path structure for task items: org_{orgId}/attachments/task-item/{entityType}/{entityId}
let s3Key: string;
if (entityType === 'task_item') {
// For task items, extract entityType and entityId from metadata
// Metadata should contain taskItemEntityType and taskItemEntityId
const taskItemEntityType =
uploadDto.description?.split('|')[0] || 'unknown';
const taskItemEntityId =
uploadDto.description?.split('|')[1] || entityId;
s3Key = `${organizationId}/attachments/task-item/${taskItemEntityType}/${taskItemEntityId}/${timestamp}-${fileId}-${sanitizedFileName}`;
} else {
s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`;
}

// Upload to S3
const putCommand = new PutObjectCommand({
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/auth/auth-context.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const AuthContext = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AuthContextType => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();

const { organizationId, authType, isApiKey, userId, userEmail } = request;
const { organizationId, authType, isApiKey, userId, userEmail, userRoles } =
request;

if (!organizationId || !authType) {
throw new Error(
Expand All @@ -23,6 +24,7 @@ export const AuthContext = createParamDecorator(
isApiKey,
userId,
userEmail,
userRoles,
};
},
);
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { ApiKeyGuard } from './api-key.guard';
import { ApiKeyService } from './api-key.service';
import { HybridAuthGuard } from './hybrid-auth.guard';
import { InternalTokenGuard } from './internal-token.guard';

@Module({
providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard],
exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard],
providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
})
export class AuthModule {}
16 changes: 16 additions & 0 deletions apps/api/src/auth/hybrid-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export class HybridAuthGuard implements CanActivate {
request.organizationId = organizationId;
request.authType = 'api-key';
request.isApiKey = true;
// API keys are organization-scoped and are not tied to a specific user/member.
request.userRoles = null;

return true;
}
Expand Down Expand Up @@ -171,9 +173,23 @@ export class HybridAuthGuard implements CanActivate {
);
}

const member = await db.member.findFirst({
where: {
userId,
organizationId: explicitOrgId,
deactivated: false,
},
select: {
role: true,
},
});

const userRoles = member?.role ? member.role.split(',') : null;

// Set request context for JWT auth
request.userId = userId;
request.userEmail = userEmail;
request.userRoles = userRoles;
request.organizationId = explicitOrgId;
request.authType = 'jwt';
request.isApiKey = false;
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/auth/internal-token.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';

type RequestWithHeaders = {
headers: Record<string, string | string[] | undefined>;
};

@Injectable()
export class InternalTokenGuard implements CanActivate {
private readonly logger = new Logger(InternalTokenGuard.name);

canActivate(context: ExecutionContext): boolean {
const expectedToken = process.env.INTERNAL_API_TOKEN;

// In production, we require the token to be configured.
if (!expectedToken) {
if (process.env.NODE_ENV === 'production') {
this.logger.error('INTERNAL_API_TOKEN is not configured in production');
throw new UnauthorizedException('Internal access is not configured');
}

// In local/dev, allow requests if not configured to keep DX smooth.
this.logger.warn(
'INTERNAL_API_TOKEN is not configured; allowing internal request in non-production',
);
return true;
}

const req = context.switchToHttp().getRequest<RequestWithHeaders>();
const headerValue = req.headers['x-internal-token'];
const token = Array.isArray(headerValue) ? headerValue[0] : headerValue;

if (!token || token !== expectedToken) {
throw new UnauthorizedException('Invalid internal token');
}

return true;
}
}


68 changes: 68 additions & 0 deletions apps/api/src/auth/role-validator.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthenticatedRequest } from './types';

@Injectable()
export class RoleValidator implements CanActivate {
private readonly unauthenticatedErrorMessage: string;
private readonly noRolesSpecifiedErrorMessage: string;
private readonly accessDeniedErrorMessage: string;
private readonly allowedRoles: string[] | null;

constructor(allowedRoles: string[] | null) {
this.allowedRoles = allowedRoles;

this.unauthenticatedErrorMessage =
'Role-based authorization requires user authentication (JWT token)';
this.noRolesSpecifiedErrorMessage = 'No roles specified for authorization';
this.accessDeniedErrorMessage =
'Access denied. User does not have the required roles: {allowedRoles}, user has roles: {userRoles}';
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();

const { userRoles, userId, organizationId, authType, isApiKey } = request;

if (!this.allowedRoles || this.allowedRoles.length === 0) {
throw new UnauthorizedException(this.noRolesSpecifiedErrorMessage);
}

// API keys are organization-scoped and not tied to a specific user/member.
// They are allowed through role-protected endpoints.
if (isApiKey || authType === 'api-key') {
if (!organizationId) {
throw new UnauthorizedException(
'Organization context required for API key authentication',
);
}

return true;
}

// JWT requests must have user context + roles for role-based authorization
if (!userId || !organizationId || !userRoles || userRoles.length === 0) {
throw new UnauthorizedException(this.unauthenticatedErrorMessage);
}

const hasRequiredRoles = this.allowedRoles.some((role) =>
userRoles.includes(role),
);

if (!hasRequiredRoles) {
throw new UnauthorizedException(
this.accessDeniedErrorMessage
.replace('{allowedRoles}', this.allowedRoles.join(', '))
.replace('{userRoles}', userRoles.join(', ')),
);
}

return true;
}
}

export const RequireRoles = (...roles: string[]) => new RoleValidator(roles);
2 changes: 2 additions & 0 deletions apps/api/src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface AuthenticatedRequest extends Request {
isApiKey: boolean;
userId?: string;
userEmail?: string;
userRoles: string[] | null;
}

export interface AuthContext {
Expand All @@ -14,4 +15,5 @@ export interface AuthContext {
isApiKey: boolean;
userId?: string; // Only available for JWT auth
userEmail?: string; // Only available for JWT auth
userRoles: string[] | null;
}
Loading
Loading