Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 backend/src/common/data-injection.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,12 @@ export enum UseCaseType {
AGENTS_VALIDATE_TABLE_AI_REQUEST = 'AGENTS_VALIDATE_TABLE_AI_REQUEST',
AGENTS_VALIDATE_CONNECTION_EDIT = 'AGENTS_VALIDATE_CONNECTION_EDIT',
AGENTS_GET_AI_CONNECTION_CONTEXT = 'AGENTS_GET_AI_CONNECTION_CONTEXT',
AGENTS_GET_AI_CONNECTION_TABLES = 'AGENTS_GET_AI_CONNECTION_TABLES',
AGENTS_GET_AI_TABLE_STRUCTURE = 'AGENTS_GET_AI_TABLE_STRUCTURE',
AGENTS_EXECUTE_AI_RAW_QUERY = 'AGENTS_EXECUTE_AI_RAW_QUERY',
AGENTS_EXECUTE_AI_AGGREGATION_PIPELINE = 'AGENTS_EXECUTE_AI_AGGREGATION_PIPELINE',
AGENTS_SCAN_AND_CREATE_SETTINGS = 'AGENTS_SCAN_AND_CREATE_SETTINGS',
AGENTS_GET_COMPANY_SUBSCRIPTION_INFO = 'AGENTS_GET_COMPANY_SUBSCRIPTION_INFO',

CREATE_TABLE_FILTERS = 'CREATE_TABLE_FILTERS',
FIND_TABLE_FILTERS = 'FIND_TABLE_FILTERS',
Expand Down
5 changes: 5 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { WinstonLogger } from './entities/logging/winston-logger.js';
import { AllExceptionsFilter } from './exceptions/all-exceptions.filter.js';
import { ValidationException } from './exceptions/custom-exceptions/validation-exception.js';
import { Constants } from './helpers/constants/constants.js';
import { publicCrudCorsMiddleware } from './middlewares/public-crud-cors.middleware.js';
import { appConfig } from './shared/config/app-config.js';

async function bootstrap() {
Expand All @@ -38,6 +39,10 @@ async function bootstrap() {

app.use(helmet());

// Wildcard CORS for the public table CRUD routes — registered before the global enableCors()
// so it owns these routes (including the OPTIONS preflight) before the global allowlist runs.
app.use(publicCrudCorsMiddleware);

app.enableCors({
origin: [
'https://app.autoadmin.org',
Expand Down
34 changes: 34 additions & 0 deletions backend/src/microservices/agents-microservice/agents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { isTest } from '../../helpers/app/is-test.js';
import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js';
import {
AiConnectionContextRO,
AiConnectionTablesRO,
AiQueryResultRO,
CompanySubscriptionInfoRO,
PermissionAllowedRO,
ValidatedUserTokenRO,
} from './data-structures/agents-responses.ds.js';
Expand All @@ -21,11 +23,14 @@ import {
GetAiTableStructureDto,
} from './dto/agents-ai-data.dtos.js';
import { ValidateConnectionEditDto, ValidateTableAiRequestDto, ValidateUserTokenDto } from './dto/agents-auth.dtos.js';
import { GetCompanySubscriptionInfoDto } from './dto/agents-company.dtos.js';
import {
IExecuteAiAggregationPipeline,
IExecuteAiRawQuery,
IGetAiConnectionContext,
IGetAiConnectionTables,
IGetAiTableStructure,
IGetCompanySubscriptionInfo,
IScanAndCreateSettings,
IValidateConnectionEdit,
IValidateTableAiRequest,
Expand All @@ -48,6 +53,8 @@ export class AgentsController {
private readonly validateConnectionEditUseCase: IValidateConnectionEdit,
@Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT)
private readonly getAiConnectionContextUseCase: IGetAiConnectionContext,
@Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_TABLES)
private readonly getAiConnectionTablesUseCase: IGetAiConnectionTables,
@Inject(UseCaseType.AGENTS_GET_AI_TABLE_STRUCTURE)
private readonly getAiTableStructureUseCase: IGetAiTableStructure,
@Inject(UseCaseType.AGENTS_EXECUTE_AI_RAW_QUERY)
Expand All @@ -56,6 +63,8 @@ export class AgentsController {
private readonly executeAiAggregationPipelineUseCase: IExecuteAiAggregationPipeline,
@Inject(UseCaseType.AGENTS_SCAN_AND_CREATE_SETTINGS)
private readonly scanAndCreateSettingsUseCase: IScanAndCreateSettings,
@Inject(UseCaseType.AGENTS_GET_COMPANY_SUBSCRIPTION_INFO)
private readonly getCompanySubscriptionInfoUseCase: IGetCompanySubscriptionInfo,
) {}

@ApiOperation({ summary: 'Validate an end-user JWT on behalf of the agents microservice' })
Expand Down Expand Up @@ -102,6 +111,21 @@ export class AgentsController {
);
}

@ApiOperation({ summary: 'List connection tables the user may read (grounds website feasibility)' })
@ApiResponse({ status: 201, type: AiConnectionTablesRO })
@ApiBody({ type: AiDataRequestBaseDto })
@Timeout(!isTest() ? TimeoutDefaults.EXTENDED : TimeoutDefaults.EXTENDED_TEST)
@Post('/ai/data/:connectionId/tables')
public async getAiConnectionTables(
@SlugUuid('connectionId') connectionId: string,
@Body() body: AiDataRequestBaseDto,
): Promise<AiConnectionTablesRO> {
return await this.getAiConnectionTablesUseCase.execute(
{ connectionId, userId: body.userId, masterPassword: body.masterPassword ?? null },
InTransactionEnum.OFF,
);
}

@ApiOperation({ summary: 'Get permission-aware table structure for the AI tool loop' })
@ApiResponse({ status: 201, description: 'Table structure with related tables.' })
@ApiBody({ type: GetAiTableStructureDto })
Expand Down Expand Up @@ -184,4 +208,14 @@ export class AgentsController {
InTransactionEnum.OFF,
);
}

@ApiOperation({ summary: "Read a user's company subscription metadata (agents-core owns all feature policy)" })
@ApiResponse({ status: 201, type: CompanySubscriptionInfoRO })
@ApiBody({ type: GetCompanySubscriptionInfoDto })
@Post('/company/subscription-info')
public async getCompanySubscriptionInfo(
@Body() body: GetCompanySubscriptionInfoDto,
): Promise<CompanySubscriptionInfoRO> {
return await this.getCompanySubscriptionInfoUseCase.execute({ userId: body.userId }, InTransactionEnum.OFF);
}
}
10 changes: 10 additions & 0 deletions backend/src/microservices/agents-microservice/agents.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { AgentsController } from './agents.controller.js';
import { ExecuteAiAggregationPipelineUseCase } from './use-cases/execute-ai-aggregation-pipeline.use.case.js';
import { ExecuteAiRawQueryUseCase } from './use-cases/execute-ai-raw-query.use.case.js';
import { GetAiConnectionContextUseCase } from './use-cases/get-ai-connection-context.use.case.js';
import { GetAiConnectionTablesUseCase } from './use-cases/get-ai-connection-tables.use.case.js';
import { GetAiTableStructureUseCase } from './use-cases/get-ai-table-structure.use.case.js';
import { GetCompanySubscriptionInfoUseCase } from './use-cases/get-company-subscription-info.use.case.js';
import { ScanAndCreateSettingsUseCase } from './use-cases/scan-and-create-settings.use.case.js';
import { ValidateConnectionEditUseCase } from './use-cases/validate-connection-edit.use.case.js';
import { ValidateTableAiRequestUseCase } from './use-cases/validate-table-ai-request.use.case.js';
Expand Down Expand Up @@ -36,6 +38,10 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca
provide: UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT,
useClass: GetAiConnectionContextUseCase,
},
{
provide: UseCaseType.AGENTS_GET_AI_CONNECTION_TABLES,
useClass: GetAiConnectionTablesUseCase,
},
{
provide: UseCaseType.AGENTS_GET_AI_TABLE_STRUCTURE,
useClass: GetAiTableStructureUseCase,
Expand All @@ -52,6 +58,10 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca
provide: UseCaseType.AGENTS_SCAN_AND_CREATE_SETTINGS,
useClass: ScanAndCreateSettingsUseCase,
},
{
provide: UseCaseType.AGENTS_GET_COMPANY_SUBSCRIPTION_INFO,
useClass: GetCompanySubscriptionInfoUseCase,
},
],
controllers: [AgentsController],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,22 @@ export class AiQueryResultRO {
@ApiProperty()
result: unknown;
}

export class AiConnectionTablesRO {
@ApiProperty({ type: [String], description: 'Table names the user is permitted to read on the connection.' })
tables: Array<string>;
}

export class CompanySubscriptionInfoRO {
@ApiProperty({ description: 'Whether the backend is running in SaaS mode. When false, no subscription applies.' })
isSaaS: boolean;

@ApiPropertyOptional({ nullable: true })
companyId: string | null;

@ApiPropertyOptional({ nullable: true, description: 'FREE_PLAN | TEAM_PLAN | ENTERPRISE_PLAN | ANNUAL_* | null' })
subscriptionLevel: string | null;
Comment on lines +56 to +57

@ApiProperty()
isPaymentMethodAdded: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ export class ExecuteAiAggregationPipelineDs extends AiDataRequestDs {
export class ScanAndCreateSettingsDs extends AiDataRequestDs {
response: Response;
}

export class GetCompanySubscriptionInfoDs {
userId: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class GetCompanySubscriptionInfoDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
userId: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import {
ExecuteAiAggregationPipelineDs,
ExecuteAiRawQueryDs,
GetAiTableStructureDs,
GetCompanySubscriptionInfoDs,
ScanAndCreateSettingsDs,
ValidateConnectionEditDs,
ValidateTableAiRequestDs,
} from '../data-structures/agents.ds.js';
import {
AiConnectionContextRO,
AiConnectionTablesRO,
AiQueryResultRO,
CompanySubscriptionInfoRO,
PermissionAllowedRO,
ValidatedUserTokenRO,
} from '../data-structures/agents-responses.ds.js';
Expand All @@ -35,6 +38,10 @@ export interface IGetAiTableStructure {
execute(inputData: GetAiTableStructureDs, inTransaction: InTransactionEnum): Promise<Record<string, unknown>>;
}

export interface IGetAiConnectionTables {
execute(inputData: AiDataRequestDs, inTransaction: InTransactionEnum): Promise<AiConnectionTablesRO>;
}

export interface IExecuteAiRawQuery {
execute(inputData: ExecuteAiRawQueryDs, inTransaction: InTransactionEnum): Promise<AiQueryResultRO>;
}
Expand All @@ -46,3 +53,10 @@ export interface IExecuteAiAggregationPipeline {
export interface IScanAndCreateSettings {
execute(inputData: ScanAndCreateSettingsDs, inTransaction: InTransactionEnum): Promise<void>;
}

export interface IGetCompanySubscriptionInfo {
execute(
inputData: GetCompanySubscriptionInfoDs,
inTransaction: InTransactionEnum,
): Promise<CompanySubscriptionInfoRO>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Inject, Injectable, Scope } from '@nestjs/common';
import AbstractUseCase from '../../../common/abstract-use.case.js';
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
import { BaseType } from '../../../common/data-injection.tokens.js';
import { CedarPermissionsService } from '../../../entities/cedar-authorization/cedar-permissions.service.js';
import { AiDataRequestDs } from '../data-structures/agents.ds.js';
import { AiConnectionTablesRO } from '../data-structures/agents-responses.ds.js';
import { setupAiConnection } from '../utils/ai-data-access.helpers.js';
import { IGetAiConnectionTables } from './agents-use-cases.interface.js';

@Injectable({ scope: Scope.REQUEST })
export class GetAiConnectionTablesUseCase
extends AbstractUseCase<AiDataRequestDs, AiConnectionTablesRO>
implements IGetAiConnectionTables
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly cedarPermissions: CedarPermissionsService,
) {
super();
}

protected async implementation(inputData: AiDataRequestDs): Promise<AiConnectionTablesRO> {
const { connectionId, userId, masterPassword } = inputData;

const { foundConnection, dataAccessObject } = await setupAiConnection(
this._dbContext,
connectionId,
masterPassword,
userId,
);

const tables = await dataAccessObject.getTablesFromDB();
const tableNames = tables.map((table) => table.tableName?.trim()).filter((name): name is string => Boolean(name));
Comment on lines +27 to +35

const readableFlags = await Promise.all(
tableNames.map((tableName) =>
this.cedarPermissions.improvedCheckTableRead(userId, foundConnection.id, tableName),
),
);
Comment on lines +37 to +41
const readableTableNames = tableNames.filter((_name, index) => readableFlags[index]);

return { tables: readableTableNames };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { HttpException, HttpStatus, Inject, Injectable, Scope } from '@nestjs/common';
import AbstractUseCase from '../../../common/abstract-use.case.js';
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
import { BaseType } from '../../../common/data-injection.tokens.js';
import { SubscriptionLevelEnum } from '../../../enums/subscription-level.enum.js';
import { Messages } from '../../../exceptions/text/messages.js';
import { isSaaS } from '../../../helpers/app/is-saas.js';
import { isTest } from '../../../helpers/app/is-test.js';
import { SaasCompanyGatewayService } from '../../gateways/saas-gateway.ts/saas-company-gateway.service.js';
import { GetCompanySubscriptionInfoDs } from '../data-structures/agents.ds.js';
import { CompanySubscriptionInfoRO } from '../data-structures/agents-responses.ds.js';
import { IGetCompanySubscriptionInfo } from './agents-use-cases.interface.js';

/**
* Thin metadata provider for the agents microservice: resolves a user's company subscription level
* via the saas gateway. The agents service (agents-core) owns all website-generation policy
* (model tier, hosting caps, quota enforcement) and only reads this subscription metadata from here.
*/
@Injectable({ scope: Scope.REQUEST })
export class GetCompanySubscriptionInfoUseCase
extends AbstractUseCase<GetCompanySubscriptionInfoDs, CompanySubscriptionInfoRO>
implements IGetCompanySubscriptionInfo
{
constructor(
@Inject(BaseType.GLOBAL_DB_CONTEXT)
protected _dbContext: IGlobalDatabaseContext,
private readonly saasCompanyGatewayService: SaasCompanyGatewayService,
) {
super();
}

protected async implementation(inputData: GetCompanySubscriptionInfoDs): Promise<CompanySubscriptionInfoRO> {
const { userId } = inputData;

// Self-hosted / non-SaaS / test runs have no subscription concept.
if (!isSaaS() || isTest()) {
return { isSaaS: false, companyId: null, subscriptionLevel: null, isPaymentMethodAdded: false };
}

const company = await this._dbContext.companyInfoRepository.findCompanyInfoByUserId(userId);
if (!company) {
throw new HttpException({ message: Messages.COMPANY_NOT_FOUND }, HttpStatus.FORBIDDEN);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const companyInfo = await this.saasCompanyGatewayService.getCompanyInfo(company.id);
return {
isSaaS: true,
companyId: company.id,
subscriptionLevel: companyInfo?.subscriptionLevel ?? SubscriptionLevelEnum.FREE_PLAN,
isPaymentMethodAdded: companyInfo?.is_payment_method_added ?? false,
};
}
}
34 changes: 34 additions & 0 deletions backend/src/middlewares/public-crud-cors.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextFunction, Request, Response } from 'express';

const PUBLIC_CRUD_ROUTE_REGEX = /\/table\/crud(\/|$)/;

/**
* Wildcard CORS for the public table CRUD routes (TablePureCrudOperationsController).
*
* These endpoints support public / api-key access and may be called from any origin.
* A literal `Access-Control-Allow-Origin: *` cannot be combined with credentials, so we reflect
* the request's Origin back instead — this allows any origin while still permitting cookie /
* credentialed requests. Must be registered before the global enableCors() so it owns these
* routes (including answering the OPTIONS preflight) before the global allowlist runs.
*/
export function publicCrudCorsMiddleware(req: Request, res: Response, next: NextFunction): void {
if (PUBLIC_CRUD_ROUTE_REGEX.test(req.path)) {
const requestOrigin = req.headers.origin;
if (requestOrigin) {
res.header('Access-Control-Allow-Origin', requestOrigin);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Vary', 'Origin');
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
res.header(
'Access-Control-Allow-Headers',
(req.headers['access-control-request-headers'] as string) ??
'Content-Type, Authorization, x-api-key, masterpwd',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
if (req.method === 'OPTIONS') {
res.sendStatus(204);
return;
}
}
next();
}
Loading
Loading