Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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"]

1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@trigger.dev/build": "4.0.6",
"@trigger.dev/sdk": "4.0.6",
"@trycompai/db": "1.3.19",
"@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 = 60 * 1024 * 1024; // 60MB
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