diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cfbfbff011..d0147d7411 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -25,6 +25,7 @@ import { ContextModule } from './context/context.module'; import { TrustPortalModule } from './trust-portal/trust-portal.module'; import { ControlTemplateModule } from './framework-editor/control-template/control-template.module'; import { IsmsDocumentTemplateModule } from './framework-editor/isms-document-template/isms-document-template.module'; +import { FrameworkFamilyModule } from './framework-editor/framework-family/framework-family.module'; import { FrameworkEditorFrameworkModule } from './framework-editor/framework/framework.module'; import { PolicyTemplateModule } from './framework-editor/policy-template/policy-template.module'; import { RequirementModule } from './framework-editor/requirement/requirement.module'; @@ -101,6 +102,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding- FrameworkEditorFrameworkModule, PolicyTemplateModule, RequirementModule, + FrameworkFamilyModule, TaskTemplateModule, FindingTemplateModule, FindingsModule, diff --git a/apps/api/src/browserbase/browser-auth-profile-context.service.ts b/apps/api/src/browserbase/browser-auth-profile-context.service.ts new file mode 100644 index 0000000000..01ecb124cd --- /dev/null +++ b/apps/api/src/browserbase/browser-auth-profile-context.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { db, type BrowserAuthProfile } from '@db'; +import { BrowserbaseSessionService } from './browserbase-session.service'; +import { BrowserbaseOrgContextService } from './browserbase-org-context.service'; +import { PENDING_CONTEXT_ID } from './browserbase-org-context.service'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +@Injectable() +export class BrowserAuthProfileContextService { + private readonly logger = new Logger(BrowserAuthProfileContextService.name); + + constructor( + private readonly sessions: BrowserbaseSessionService = new BrowserbaseSessionService(), + private readonly orgContexts: BrowserbaseOrgContextService = new BrowserbaseOrgContextService( + sessions, + ), + ) {} + + async initialize(input: { + profileId: string; + organizationId: string; + }): Promise { + try { + const contextId = await this.resolveInitialContextId( + input.organizationId, + ); + return await db.browserAuthProfile.update({ + where: { id: input.profileId }, + data: { contextId }, + }); + } catch (error) { + await this.deletePendingProfile(input.profileId); + throw error; + } + } + + async ready(profile: BrowserAuthProfile): Promise { + if (profile.contextId !== PENDING_CONTEXT_ID) return profile; + return this.waitForProfileContext(profile.id); + } + + private async resolveInitialContextId( + organizationId: string, + ): Promise { + const legacy = await this.orgContexts.getOrgContext(organizationId); + if (legacy) return legacy.contextId; + return this.sessions.createBrowserbaseContext(); + } + + private async waitForProfileContext( + profileId: string, + ): Promise { + const maxWaitMs = 10_000; + const pollMs = 200; + const startedAt = Date.now(); + + while (Date.now() - startedAt < maxWaitMs) { + const current = await db.browserAuthProfile.findUnique({ + where: { id: profileId }, + }); + + if (current && current.contextId !== PENDING_CONTEXT_ID) { + return current; + } + + if (!current) { + throw new NotFoundException('Browser auth profile not found'); + } + + await delay(pollMs); + } + + this.logger.warn( + `Timed out waiting for Browser auth profile context ${profileId}`, + ); + throw new Error( + 'Browser profile initialization is taking too long. Please retry.', + ); + } + + private async deletePendingProfile(profileId: string): Promise { + try { + await db.browserAuthProfile.deleteMany({ + where: { id: profileId, contextId: PENDING_CONTEXT_ID }, + }); + } catch (error) { + this.logger.warn('Failed to clear pending browser auth profile', { + profileId, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/apps/api/src/browserbase/browser-auth-profile.service.spec.ts b/apps/api/src/browserbase/browser-auth-profile.service.spec.ts new file mode 100644 index 0000000000..64470d6be0 --- /dev/null +++ b/apps/api/src/browserbase/browser-auth-profile.service.spec.ts @@ -0,0 +1,191 @@ +import { BrowserbaseSessionService } from './browserbase-session.service'; +import { BrowserAuthProfileService } from './browser-auth-profile.service'; + +jest.mock('@db', () => ({ + db: { + browserAuthProfile: { + findMany: jest.fn(), + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + deleteMany: jest.fn(), + }, + browserbaseContext: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + deleteMany: jest.fn(), + }, + }, +})); + +import { db } from '@db'; + +describe('BrowserAuthProfileService', () => { + let sessions: BrowserbaseSessionService; + let service: BrowserAuthProfileService; + + beforeEach(() => { + jest.clearAllMocks(); + sessions = new BrowserbaseSessionService(); + jest + .spyOn(sessions, 'createBrowserbaseContext') + .mockResolvedValue('ctx_new'); + service = new BrowserAuthProfileService(sessions); + }); + + it('normalizes hostname and login identity when creating a profile', async () => { + (db.browserAuthProfile.findUnique as jest.Mock).mockResolvedValue(null); + (db.browserbaseContext.findUnique as jest.Mock).mockResolvedValue(null); + (db.browserAuthProfile.create as jest.Mock).mockResolvedValue({ + id: 'bap_1', + organizationId: 'org_1', + hostname: 'github.com', + loginIdentity: 'svc@example.com', + contextId: '__PENDING__', + }); + (db.browserAuthProfile.update as jest.Mock).mockResolvedValue({ + id: 'bap_1', + organizationId: 'org_1', + hostname: 'github.com', + loginIdentity: 'svc@example.com', + contextId: 'ctx_new', + }); + + await service.getOrCreateProfileFromUrl({ + organizationId: 'org_1', + url: 'https://GitHub.com/acme/repo', + loginIdentity: ' SVC@EXAMPLE.COM ', + }); + + expect(db.browserAuthProfile.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + organizationId: 'org_1', + hostname: 'github.com', + loginIdentity: 'svc@example.com', + contextId: '__PENDING__', + }), + }); + expect(db.browserAuthProfile.update).toHaveBeenCalledWith({ + where: { id: 'bap_1' }, + data: { contextId: 'ctx_new' }, + }); + }); + + it('does not create an orphan context when another request creates the profile', async () => { + (db.browserAuthProfile.findUnique as jest.Mock).mockResolvedValue(null); + (db.browserAuthProfile.create as jest.Mock).mockRejectedValue({ + code: 'P2002', + }); + (db.browserAuthProfile.findUniqueOrThrow as jest.Mock).mockResolvedValue({ + id: 'bap_existing', + organizationId: 'org_1', + hostname: 'github.com', + loginIdentity: '', + contextId: 'ctx_existing', + }); + + const result = await service.getOrCreateProfileFromUrl({ + organizationId: 'org_1', + url: 'https://github.com/acme/repo', + }); + + expect(result.profile.id).toBe('bap_existing'); + expect(sessions.createBrowserbaseContext).not.toHaveBeenCalled(); + }); + + it('prioritizes a verified profile for the target hostname', async () => { + (db.browserAuthProfile.findMany as jest.Mock).mockResolvedValue([ + { id: 'bap_old', status: 'needs_reauth', hostname: 'github.com' }, + { id: 'bap_verified', status: 'verified', hostname: 'github.com' }, + ]); + + const profile = await service.resolveProfileForTarget({ + organizationId: 'org_1', + targetUrl: 'https://github.com/acme/repo', + }); + + expect(profile.id).toBe('bap_verified'); + expect(db.browserAuthProfile.findMany).toHaveBeenCalledWith({ + where: { organizationId: 'org_1', hostname: 'github.com' }, + orderBy: { updatedAt: 'desc' }, + }); + }); + + it('rejects profile verification for a different hostname', async () => { + jest + .spyOn(sessions, 'checkLoginStatus') + .mockResolvedValue({ isLoggedIn: true }); + (db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue({ + id: 'bap_1', + organizationId: 'org_1', + hostname: 'github.com', + lastVerifiedAt: null, + }); + + await expect( + service.verifyProfileSession({ + organizationId: 'org_1', + profileId: 'bap_1', + sessionId: 'sess_1', + url: 'https://gitlab.com/acme/repo', + }), + ).rejects.toThrow('Verification URL must match'); + expect(sessions.checkLoginStatus).not.toHaveBeenCalled(); + }); + + it('rejects profile verification when the session uses another context', async () => { + jest.spyOn(sessions, 'getSessionContextId').mockResolvedValue('ctx_other'); + jest + .spyOn(sessions, 'checkLoginStatus') + .mockResolvedValue({ isLoggedIn: true }); + (db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue({ + id: 'bap_1', + organizationId: 'org_1', + hostname: 'github.com', + contextId: 'ctx_profile', + lastVerifiedAt: null, + }); + + await expect( + service.verifyProfileSession({ + organizationId: 'org_1', + profileId: 'bap_1', + sessionId: 'sess_wrong', + url: 'https://github.com/acme/repo', + }), + ).rejects.toThrow('does not belong to this auth profile'); + expect(sessions.checkLoginStatus).not.toHaveBeenCalled(); + expect(db.browserAuthProfile.update).not.toHaveBeenCalled(); + }); + + it('treats a pending legacy org context as unavailable', async () => { + (db.browserbaseContext.findUnique as jest.Mock).mockResolvedValue({ + organizationId: 'org_1', + contextId: '__PENDING__', + }); + + await expect(service.getOrgContext('org_1')).resolves.toBeNull(); + }); + + it('clears pending org context when Browserbase context creation fails', async () => { + (db.browserbaseContext.findUnique as jest.Mock).mockResolvedValue(null); + (db.browserbaseContext.create as jest.Mock).mockResolvedValue({ + organizationId: 'org_1', + contextId: '__PENDING__', + }); + jest + .spyOn(sessions, 'createBrowserbaseContext') + .mockRejectedValue(new Error('Browserbase unavailable')); + + await expect(service.getOrCreateOrgContext('org_1')).rejects.toThrow( + 'Browserbase unavailable', + ); + expect(db.browserbaseContext.deleteMany).toHaveBeenCalledWith({ + where: { organizationId: 'org_1', contextId: '__PENDING__' }, + }); + }); +}); diff --git a/apps/api/src/browserbase/browser-auth-profile.service.ts b/apps/api/src/browserbase/browser-auth-profile.service.ts new file mode 100644 index 0000000000..0d18c60932 --- /dev/null +++ b/apps/api/src/browserbase/browser-auth-profile.service.ts @@ -0,0 +1,290 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { + defaultProfileDisplayName, + normalizeHostnameFromUrl, + normalizeLoginIdentity, +} from './browserbase-url'; +import { BrowserbaseSessionService } from './browserbase-session.service'; +import { BrowserAuthProfileContextService } from './browser-auth-profile-context.service'; +import { + BrowserbaseOrgContextService, + PENDING_CONTEXT_ID, + isPrismaUniqueConstraintError, +} from './browserbase-org-context.service'; + +export interface AuthProfileInput { + organizationId: string; + url: string; + displayName?: string; + loginIdentity?: string; + vaultProvider?: string; + vaultExternalItemRef?: string; + vaultConnectionId?: string; +} + +@Injectable() +export class BrowserAuthProfileService { + constructor( + private readonly sessions: BrowserbaseSessionService = new BrowserbaseSessionService(), + private readonly orgContexts: BrowserbaseOrgContextService = new BrowserbaseOrgContextService( + sessions, + ), + private readonly profileContexts: BrowserAuthProfileContextService = new BrowserAuthProfileContextService( + sessions, + orgContexts, + ), + ) {} + + async listProfiles(organizationId: string) { + return db.browserAuthProfile.findMany({ + where: { organizationId }, + orderBy: [{ hostname: 'asc' }, { updatedAt: 'desc' }], + }); + } + + async getProfile({ + profileId, + organizationId, + }: { + profileId: string; + organizationId: string; + }) { + return db.browserAuthProfile.findFirst({ + where: { id: profileId, organizationId }, + }); + } + + async getOrCreateProfileFromUrl(input: AuthProfileInput) { + const hostname = normalizeHostnameFromUrl(input.url); + const loginIdentity = normalizeLoginIdentity(input.loginIdentity); + + const existing = await db.browserAuthProfile.findUnique({ + where: { + organizationId_hostname_loginIdentity: { + organizationId: input.organizationId, + hostname, + loginIdentity, + }, + }, + }); + + if (existing) { + return { + profile: await this.profileContexts.ready(existing), + isNew: false, + }; + } + + try { + const pendingProfile = await db.browserAuthProfile.create({ + data: { + organizationId: input.organizationId, + hostname, + loginIdentity, + displayName: + input.displayName?.trim() || defaultProfileDisplayName(hostname), + contextId: PENDING_CONTEXT_ID, + lastAuthCheckUrl: input.url, + vaultProvider: input.vaultProvider, + vaultExternalItemRef: input.vaultExternalItemRef, + vaultConnectionId: input.vaultConnectionId, + }, + }); + const profile = await this.profileContexts.initialize({ + profileId: pendingProfile.id, + organizationId: input.organizationId, + }); + return { profile, isNew: true }; + } catch (error) { + if (!isPrismaUniqueConstraintError(error)) { + throw error; + } + + const profile = await db.browserAuthProfile.findUniqueOrThrow({ + where: { + organizationId_hostname_loginIdentity: { + organizationId: input.organizationId, + hostname, + loginIdentity, + }, + }, + }); + return { + profile: await this.profileContexts.ready(profile), + isNew: false, + }; + } + } + + async resolveProfileForTarget(input: { + organizationId: string; + targetUrl: string; + profileId?: string; + }) { + if (input.profileId) { + const profile = await this.getProfile({ + profileId: input.profileId, + organizationId: input.organizationId, + }); + if (!profile) { + throw new NotFoundException('Browser auth profile not found'); + } + return this.profileContexts.ready(profile); + } + + const hostname = normalizeHostnameFromUrl(input.targetUrl); + const profiles = await db.browserAuthProfile.findMany({ + where: { organizationId: input.organizationId, hostname }, + orderBy: { updatedAt: 'desc' }, + }); + + const verified = profiles.find((profile) => profile.status === 'verified'); + if (verified) return this.profileContexts.ready(verified); + if (profiles[0]) return this.profileContexts.ready(profiles[0]); + + const created = await this.getOrCreateProfileFromUrl({ + organizationId: input.organizationId, + url: input.targetUrl, + }); + return created.profile; + } + + async startProfileSession(input: { + organizationId: string; + profileId: string; + }): Promise<{ sessionId: string; liveViewUrl: string }> { + const profile = await this.getProfile(input); + if (!profile) { + throw new NotFoundException('Browser auth profile not found'); + } + const readyProfile = await this.profileContexts.ready(profile); + return this.sessions.createSessionWithContext(readyProfile.contextId); + } + + async verifyProfileSession(input: { + organizationId: string; + profileId: string; + sessionId: string; + url: string; + }) { + const profile = await this.getProfile({ + organizationId: input.organizationId, + profileId: input.profileId, + }); + if (!profile) { + throw new NotFoundException('Browser auth profile not found'); + } + this.assertUrlMatchesProfileHostname({ + url: input.url, + profileHostname: profile.hostname, + }); + await this.assertSessionMatchesProfile({ + sessionId: input.sessionId, + profileContextId: profile.contextId, + }); + + const auth = await this.sessions.checkLoginStatus( + input.sessionId, + input.url, + ); + const status = auth.isLoggedIn ? 'verified' : 'needs_reauth'; + const updated = await db.browserAuthProfile.update({ + where: { id: profile.id }, + data: { + status, + lastVerifiedAt: auth.isLoggedIn ? new Date() : profile.lastVerifiedAt, + lastAuthCheckUrl: input.url, + blockedReason: auth.isLoggedIn ? null : 'Login verification failed.', + }, + }); + + return { profile: updated, auth }; + } + + async markNeedsReauth(input: { + organizationId: string; + profileId: string; + reason?: string; + }) { + const profile = await this.getProfile(input); + if (!profile) { + throw new NotFoundException('Browser auth profile not found'); + } + + return db.browserAuthProfile.update({ + where: { id: profile.id }, + data: { + status: 'needs_reauth', + blockedReason: input.reason ?? 'Authentication needs to be refreshed.', + }, + }); + } + + async markBlocked(input: { + organizationId: string; + profileId: string; + reason: string; + }) { + const profile = await this.getProfile(input); + if (!profile) { + throw new NotFoundException('Browser auth profile not found'); + } + + return db.browserAuthProfile.update({ + where: { id: profile.id }, + data: { + status: 'blocked', + blockedReason: input.reason, + }, + }); + } + + async getOrCreateOrgContext( + organizationId: string, + ): Promise<{ contextId: string; isNew: boolean }> { + return this.orgContexts.getOrCreateOrgContext(organizationId); + } + + async getOrgContext( + organizationId: string, + ): Promise<{ contextId: string } | null> { + return this.orgContexts.getOrgContext(organizationId); + } + + private assertUrlMatchesProfileHostname(input: { + url: string; + profileHostname: string; + }): void { + let hostname: string; + try { + hostname = normalizeHostnameFromUrl(input.url); + } catch { + throw new BadRequestException('Invalid verification URL'); + } + + if (hostname !== input.profileHostname) { + throw new BadRequestException( + 'Verification URL must match the browser auth profile hostname.', + ); + } + } + + private async assertSessionMatchesProfile(input: { + sessionId: string; + profileContextId: string; + }): Promise { + const sessionContextId = await this.sessions.getSessionContextId( + input.sessionId, + ); + if (sessionContextId === input.profileContextId) return; + + throw new BadRequestException( + 'Browser session does not belong to this auth profile.', + ); + } +} diff --git a/apps/api/src/browserbase/browser-auth-profiles.controller.ts b/apps/api/src/browserbase/browser-auth-profiles.controller.ts new file mode 100644 index 0000000000..10d2c2b347 --- /dev/null +++ b/apps/api/src/browserbase/browser-auth-profiles.controller.ts @@ -0,0 +1,136 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { BrowserbaseService } from './browserbase.service'; +import { + BrowserAuthProfileResponseDto, + MarkAuthProfileNeedsReauthDto, + ResolveAuthProfileDto, + ResolveAuthProfileResponseDto, + SessionResponseDto, + VerifyAuthProfileResponseDto, + VerifyAuthProfileSessionDto, +} from './dto/browserbase.dto'; + +@ApiTags('Browserbase') +@Controller({ path: 'browserbase', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class BrowserAuthProfilesController { + constructor(private readonly browserbaseService: BrowserbaseService) {} + + @Get('profiles') + @RequirePermission('integration', 'read') + @ApiOperation({ + summary: 'List browser auth profiles', + description: + 'List per-site browser auth profiles for the organization, including hostname, verification status, and vault metadata references.', + }) + @ApiResponse({ + status: 200, + type: [BrowserAuthProfileResponseDto], + }) + async listProfiles( + @OrganizationId() organizationId: string, + ): Promise { + return (await this.browserbaseService.listAuthProfiles( + organizationId, + )) as BrowserAuthProfileResponseDto[]; + } + + @Post('profiles/resolve') + @RequirePermission('integration', 'create') + @ApiOperation({ + summary: 'Create or get browser auth profile', + description: + 'Normalize a website URL to a hostname and create or reuse the matching browser auth profile for that org and login identity.', + }) + @ApiBody({ type: ResolveAuthProfileDto }) + @ApiResponse({ + status: 201, + type: ResolveAuthProfileResponseDto, + }) + async resolveProfile( + @OrganizationId() organizationId: string, + @Body() dto: ResolveAuthProfileDto, + ): Promise { + return (await this.browserbaseService.getOrCreateAuthProfile({ + organizationId, + ...dto, + })) as ResolveAuthProfileResponseDto; + } + + @Post('profiles/:profileId/session') + @RequirePermission('integration', 'read') + @ApiOperation({ + summary: 'Start browser auth profile session', + description: + 'Create a Browserbase Live View session using a specific auth profile context so a user can log in or complete 2FA manually.', + }) + @ApiParam({ name: 'profileId', description: 'Browser auth profile ID' }) + @ApiResponse({ status: 201, type: SessionResponseDto }) + async startProfileSession( + @OrganizationId() organizationId: string, + @Param('profileId') profileId: string, + ): Promise { + return await this.browserbaseService.startAuthProfileSession({ + organizationId, + profileId, + }); + } + + @Post('profiles/:profileId/verify') + @RequirePermission('integration', 'update') + @ApiOperation({ + summary: 'Verify browser auth profile session', + description: + 'Check whether the Live View session is authenticated for the profile URL and update the profile status to verified or needs reauth.', + }) + @ApiParam({ name: 'profileId', description: 'Browser auth profile ID' }) + @ApiBody({ type: VerifyAuthProfileSessionDto }) + @ApiResponse({ status: 200, type: VerifyAuthProfileResponseDto }) + async verifyProfileSession( + @OrganizationId() organizationId: string, + @Param('profileId') profileId: string, + @Body() dto: VerifyAuthProfileSessionDto, + ): Promise { + return (await this.browserbaseService.verifyAuthProfileSession({ + organizationId, + profileId, + sessionId: dto.sessionId, + url: dto.url, + })) as VerifyAuthProfileResponseDto; + } + + @Post('profiles/:profileId/needs-reauth') + @RequirePermission('integration', 'update') + @ApiOperation({ + summary: 'Mark browser auth profile needs reauth', + description: + 'Mark a browser auth profile as needing user reauthentication without storing any raw credentials or TOTP secrets.', + }) + @ApiParam({ name: 'profileId', description: 'Browser auth profile ID' }) + @ApiBody({ type: MarkAuthProfileNeedsReauthDto }) + @ApiResponse({ status: 200, type: BrowserAuthProfileResponseDto }) + async markNeedsReauth( + @OrganizationId() organizationId: string, + @Param('profileId') profileId: string, + @Body() dto: MarkAuthProfileNeedsReauthDto, + ): Promise { + return (await this.browserbaseService.markAuthProfileNeedsReauth({ + organizationId, + profileId, + reason: dto.reason, + })) as BrowserAuthProfileResponseDto; + } +} diff --git a/apps/api/src/browserbase/browser-automation-crud.service.ts b/apps/api/src/browserbase/browser-automation-crud.service.ts new file mode 100644 index 0000000000..d13d67afc0 --- /dev/null +++ b/apps/api/src/browserbase/browser-automation-crud.service.ts @@ -0,0 +1,203 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db, TaskFrequency } from '@db'; +import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; + +const normalizeCriteria = (value: string | null | undefined): string | null => { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed.length === 0 ? null : trimmed; +}; + +@Injectable() +export class BrowserAutomationCrudService { + constructor( + private readonly screenshots: BrowserbaseScreenshotService = new BrowserbaseScreenshotService(), + ) {} + + async createBrowserAutomation( + data: { + taskId: string; + name: string; + description?: string; + targetUrl: string; + instruction: string; + evaluationCriteria?: string; + scheduleFrequency?: TaskFrequency; + }, + organizationId?: string, + ) { + if (organizationId) { + await this.requireTaskInOrg({ taskId: data.taskId, organizationId }); + } + + return db.browserAutomation.create({ + data: { + taskId: data.taskId, + name: data.name, + description: data.description, + targetUrl: data.targetUrl, + instruction: data.instruction, + evaluationCriteria: normalizeCriteria(data.evaluationCriteria), + isEnabled: true, + ...(data.scheduleFrequency !== undefined + ? { scheduleFrequency: data.scheduleFrequency } + : {}), + }, + }); + } + + async getBrowserAutomation(automationId: string, organizationId?: string) { + const automation = await db.browserAutomation.findUnique({ + where: { id: automationId }, + include: { + task: { select: { organizationId: true } }, + runs: { orderBy: { createdAt: 'desc' }, take: 10 }, + }, + }); + return this.hideCrossOrgAutomation({ automation, organizationId }); + } + + async getBrowserAutomationsForTask(taskId: string, organizationId?: string) { + if (organizationId) { + await this.requireTaskInOrg({ taskId, organizationId }); + } + + return db.browserAutomation.findMany({ + where: { taskId }, + include: { + runs: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async updateBrowserAutomation( + automationId: string, + data: { + name?: string; + description?: string; + targetUrl?: string; + instruction?: string; + evaluationCriteria?: string; + isEnabled?: boolean; + scheduleFrequency?: TaskFrequency; + }, + organizationId?: string, + ) { + if (organizationId) { + await this.requireAutomationInOrg({ automationId, organizationId }); + } + + const { evaluationCriteria, scheduleFrequency, ...rest } = data; + return db.browserAutomation.update({ + where: { id: automationId }, + data: { + ...rest, + ...(evaluationCriteria !== undefined + ? { evaluationCriteria: normalizeCriteria(evaluationCriteria) } + : {}), + ...(scheduleFrequency !== undefined ? { scheduleFrequency } : {}), + }, + }); + } + + async deleteBrowserAutomation(automationId: string, organizationId?: string) { + if (organizationId) { + await this.requireAutomationInOrg({ automationId, organizationId }); + } + return db.browserAutomation.delete({ where: { id: automationId } }); + } + + async getRunWithPresignedUrl(runId: string, organizationId?: string) { + const run = await db.browserAutomationRun.findUnique({ + where: { id: runId }, + include: { automation: { include: { task: true } } }, + }); + if (!run) return null; + if (organizationId && run.automation.task.organizationId !== organizationId) { + return null; + } + if (!run.screenshotUrl) return run; + const screenshotUrl = await this.screenshots.getPresignedUrl({ + key: run.screenshotUrl, + }); + return { ...run, screenshotUrl }; + } + + async getAutomationsWithPresignedUrls(taskId: string, organizationId?: string) { + const automations = await this.getBrowserAutomationsForTask(taskId, organizationId); + return Promise.all( + automations.map(async (automation) => { + const runsWithUrls = await Promise.all( + automation.runs.map(async (run) => { + if (!run.screenshotUrl) return run; + const screenshotUrl = await this.screenshots.getPresignedUrl({ + key: run.screenshotUrl, + }); + return { ...run, screenshotUrl }; + }), + ); + return { ...automation, runs: runsWithUrls }; + }), + ); + } + + async getAutomationRuns( + automationId: string, + limit = 20, + organizationId?: string, + ) { + if (organizationId) { + await this.requireAutomationInOrg({ automationId, organizationId }); + } + return db.browserAutomationRun.findMany({ + where: { automationId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } + + async getAutomationRun(runId: string, organizationId?: string) { + return this.getRunWithPresignedUrl(runId, organizationId); + } + + private hideCrossOrgAutomation({ + automation, + organizationId, + }: { + automation: T | null; + organizationId?: string; + }): T | null { + if (!automation) return null; + if (organizationId && automation.task.organizationId !== organizationId) { + return null; + } + return automation; + } + + private async requireAutomationInOrg(input: { + automationId: string; + organizationId: string; + }) { + const automation = await db.browserAutomation.findUnique({ + where: { id: input.automationId }, + include: { task: { select: { organizationId: true } } }, + }); + const scoped = this.hideCrossOrgAutomation({ + automation, + organizationId: input.organizationId, + }); + if (!scoped) throw new NotFoundException('Automation not found'); + } + + private async requireTaskInOrg(input: { + taskId: string; + organizationId: string; + }) { + const task = await db.task.findFirst({ + where: { id: input.taskId, organizationId: input.organizationId }, + select: { id: true }, + }); + if (!task) throw new NotFoundException('Task not found'); + } +} diff --git a/apps/api/src/browserbase/browser-automation-errors.spec.ts b/apps/api/src/browserbase/browser-automation-errors.spec.ts new file mode 100644 index 0000000000..8fd898a0a4 --- /dev/null +++ b/apps/api/src/browserbase/browser-automation-errors.spec.ts @@ -0,0 +1,64 @@ +import { classifyBrowserAutomationError } from './browser-automation-errors'; + +describe('classifyBrowserAutomationError', () => { + it('classifies auth expiry as needs_reauth', () => { + const result = classifyBrowserAutomationError( + new Error('Session expired. User is not logged in.'), + 'auth', + ); + + expect(result.code).toBe('needs_reauth'); + expect(result.stage).toBe('auth'); + expect(result.needsReauth).toBe(true); + }); + + it('classifies 2FA and device approval as needs_user_action', () => { + const result = classifyBrowserAutomationError( + new Error( + 'Login paused because device approval required before continuing', + ), + 'auth', + ); + + expect(result.code).toBe('needs_user_action'); + expect(result.blockedReason).toContain('Manual 2FA'); + }); + + it('classifies captcha and rate limit failures with stable codes', () => { + expect( + classifyBrowserAutomationError(new Error('reCAPTCHA required')).code, + ).toBe('captcha_blocked'); + expect( + classifyBrowserAutomationError(new Error('429 Too Many Requests')).code, + ).toBe('rate_limited'); + }); + + it('does not expose raw unknown error text to users', () => { + const result = classifyBrowserAutomationError( + new Error('internal token abc123'), + ); + + expect(result.code).toBe('unknown'); + expect(result.userFacing).toBe( + 'Browser automation failed for an unknown reason.', + ); + }); + + it('does not classify generic forbidden errors as reauth', () => { + const result = classifyBrowserAutomationError( + new Error('403 forbidden while loading report'), + 'navigation', + ); + + expect(result.code).toBe('unknown'); + expect(result.needsReauth).toBe(false); + }); + + it('reads message fields from non-Error thrown objects', () => { + const result = classifyBrowserAutomationError({ + message: 'Target closed while taking screenshot', + }); + + expect(result.code).toBe('browser_session_lost'); + }); +}); diff --git a/apps/api/src/browserbase/browser-automation-errors.ts b/apps/api/src/browserbase/browser-automation-errors.ts new file mode 100644 index 0000000000..b1dc6db3a1 --- /dev/null +++ b/apps/api/src/browserbase/browser-automation-errors.ts @@ -0,0 +1,162 @@ +export type BrowserAutomationFailureCode = + | 'needs_reauth' + | 'needs_user_action' + | 'rate_limited' + | 'captcha_blocked' + | 'timeout' + | 'browser_session_lost' + | 'evaluation_failed' + | 'unknown'; + +export type BrowserAutomationFailureStage = + | 'auth' + | 'navigation' + | 'action' + | 'screenshot' + | 'evaluation' + | 'upload' + | 'session' + | 'unknown'; + +export interface ClassifiedBrowserAutomationError { + code: BrowserAutomationFailureCode; + stage: BrowserAutomationFailureStage; + userFacing: string; + needsReauth: boolean; + blockedReason?: string; +} + +const getErrorText = (error: unknown): string => { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (typeof error === 'object' && error !== null && 'message' in error) { + const message = error.message; + if (typeof message === 'string') return message; + } + if (typeof error === 'object' && error !== null && 'error' in error) { + const nestedError = error.error; + if (typeof nestedError === 'string') return nestedError; + } + return String(error); +}; + +export function classifyBrowserAutomationError( + error: unknown, + stage: BrowserAutomationFailureStage = 'unknown', +): ClassifiedBrowserAutomationError { + const message = getErrorText(error); + const lower = message.toLowerCase(); + + if ( + lower.includes('two-factor') || + lower.includes('2fa') || + lower.includes('totp') || + lower.includes('device approval') || + lower.includes('approval required') || + lower.includes('verify your identity') + ) { + return { + code: 'needs_user_action', + stage: 'auth', + userFacing: + 'The site requires a user action such as 2FA or device approval.', + needsReauth: true, + blockedReason: 'Manual 2FA or device approval is required.', + }; + } + + if ( + lower.includes('session expired') || + lower.includes('not logged in') || + lower.includes('not signed in') || + lower.includes('signed out') || + lower.includes('login required') || + lower.includes('log in required') || + lower.includes('sign in required') || + lower.includes('please log in') || + lower.includes('please login') || + lower.includes('401 unauthorized') || + lower.includes('http 401') || + lower.includes('unauthorized. please log in') || + lower.includes('unauthorized. please login') + ) { + return { + code: 'needs_reauth', + stage: 'auth', + userFacing: + 'Authentication is no longer valid. Reconnect this browser profile.', + needsReauth: true, + blockedReason: 'Authentication expired or could not be verified.', + }; + } + + if (lower.includes('captcha') || lower.includes('recaptcha')) { + return { + code: 'captcha_blocked', + stage: 'auth', + userFacing: + 'The site presented a captcha that automation cannot complete.', + needsReauth: false, + blockedReason: 'Captcha challenge blocked automation.', + }; + } + + if ( + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('429') + ) { + return { + code: 'rate_limited', + stage, + userFacing: 'The site or Browserbase rate limited this automation.', + needsReauth: false, + }; + } + + if ( + lower.includes('timeout') || + lower.includes('timed out') || + lower.includes('deadline') + ) { + return { + code: 'timeout', + stage, + userFacing: 'The browser automation timed out before it could finish.', + needsReauth: false, + }; + } + + if ( + lower.includes('no page') || + lower.includes('page closed') || + lower.includes('target closed') || + lower.includes('browser has been closed') || + lower.includes('session ended') + ) { + return { + code: 'browser_session_lost', + stage: 'session', + userFacing: 'The browser session ended before the automation finished.', + needsReauth: false, + }; + } + + return { + code: 'unknown', + stage, + userFacing: 'Browser automation failed for an unknown reason.', + needsReauth: false, + }; +} + +export function evaluationFailedError( + message: string, +): ClassifiedBrowserAutomationError { + return { + code: 'evaluation_failed', + stage: 'evaluation', + userFacing: message, + needsReauth: false, + }; +} diff --git a/apps/api/src/browserbase/browser-automation-execution.service.spec.ts b/apps/api/src/browserbase/browser-automation-execution.service.spec.ts new file mode 100644 index 0000000000..67e0acfa31 --- /dev/null +++ b/apps/api/src/browserbase/browser-automation-execution.service.spec.ts @@ -0,0 +1,117 @@ +import { db } from '@db'; +import { BrowserAuthProfileService } from './browser-auth-profile.service'; +import { BrowserAutomationExecutionService } from './browser-automation-execution.service'; +import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; +import { BrowserbaseSessionService } from './browserbase-session.service'; + +jest.mock('@db', () => ({ + db: { + $transaction: jest.fn(), + browserAutomation: { findUnique: jest.fn() }, + browserAutomationRun: { updateMany: jest.fn(), findUnique: jest.fn() }, + }, + Prisma: { + TransactionIsolationLevel: { Serializable: 'Serializable' }, + }, +})); + +describe('BrowserAutomationExecutionService', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (db.$transaction as jest.Mock).mockImplementation(async (callback) => + callback({ + browserAutomationRun: { + count: jest.fn().mockResolvedValue(0), + create: jest.fn().mockResolvedValue({ + id: 'bar_1', + automationId: 'bau_1', + profileId: 'bap_1', + startedAt: new Date('2026-06-19T12:00:00.000Z'), + }), + }, + }), + ); + (db.browserAutomation.findUnique as jest.Mock).mockResolvedValue({ + id: 'bau_1', + taskId: 'tsk_1', + targetUrl: 'https://example.com', + instruction: 'collect evidence', + evaluationCriteria: null, + task: { organizationId: 'org_1' }, + }); + (db.browserAutomationRun.updateMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + }); + + it('persists a failed terminal state when the runner throws', async () => { + const sessions = new BrowserbaseSessionService(); + const profiles = new BrowserAuthProfileService(sessions); + const runner = new BrowserEvidenceRunnerService(sessions); + + jest.spyOn(profiles, 'resolveProfileForTarget').mockResolvedValue({ + id: 'bap_1', + organizationId: 'org_1', + hostname: 'example.com', + loginIdentity: '', + displayName: 'example.com browser profile', + contextId: 'ctx_1', + status: 'verified', + lastVerifiedAt: null, + lastAuthCheckUrl: null, + blockedReason: null, + vaultProvider: null, + vaultExternalItemRef: null, + vaultConnectionId: null, + createdAt: new Date('2026-06-19T12:00:00.000Z'), + updatedAt: new Date('2026-06-19T12:00:00.000Z'), + }); + jest + .spyOn(runner, 'runEvidence') + .mockRejectedValue(new Error('Target closed')); + + const service = new BrowserAutomationExecutionService( + sessions, + profiles, + runner, + ); + + const response = await service.runBrowserAutomation('bau_1', 'org_1'); + + expect(response.success).toBe(false); + expect(response.failureCode).toBe('browser_session_lost'); + expect(db.browserAutomationRun.updateMany).toHaveBeenCalledWith({ + where: { id: 'bar_1', status: 'running' }, + data: expect.objectContaining({ + status: 'failed', + failureCode: 'browser_session_lost', + failureStage: 'session', + }), + }); + }); + + it('rejects live-session replay when the run is already terminal', async () => { + const sessions = new BrowserbaseSessionService(); + const profiles = new BrowserAuthProfileService(sessions); + const runner = new BrowserEvidenceRunnerService(sessions); + + (db.browserAutomationRun.findUnique as jest.Mock).mockResolvedValue({ + id: 'bar_1', + automationId: 'bau_1', + status: 'completed', + }); + const runSpy = jest.spyOn(runner, 'executeEvidenceOnSession'); + + const service = new BrowserAutomationExecutionService( + sessions, + profiles, + runner, + ); + + await expect( + service.executeAutomationOnSession('bau_1', 'bar_1', 'sess_1', 'org_1'), + ).rejects.toThrow('Run is no longer active'); + expect(runSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/browserbase/browser-automation-execution.service.ts b/apps/api/src/browserbase/browser-automation-execution.service.ts new file mode 100644 index 0000000000..098082fd50 --- /dev/null +++ b/apps/api/src/browserbase/browser-automation-execution.service.ts @@ -0,0 +1,276 @@ +import { + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { BrowserAuthProfileService } from './browser-auth-profile.service'; +import { BrowserAutomationRunStoreService } from './browser-automation-run-store.service'; +import { failedBrowserEvidenceRunResult } from './browser-automation-run-result'; +import { + BrowserEvidenceRunnerService, + type BrowserEvidenceRunResult, +} from './browser-evidence-runner.service'; +import { BrowserbaseSessionService } from './browserbase-session.service'; + +@Injectable() +export class BrowserAutomationExecutionService { + private readonly logger = new Logger(BrowserAutomationExecutionService.name); + + constructor( + private readonly sessions: BrowserbaseSessionService = new BrowserbaseSessionService(), + private readonly profiles: BrowserAuthProfileService = new BrowserAuthProfileService( + sessions, + ), + private readonly runner: BrowserEvidenceRunnerService = new BrowserEvidenceRunnerService( + sessions, + ), + private readonly runs: BrowserAutomationRunStoreService = new BrowserAutomationRunStoreService(), + ) {} + + async startAutomationWithLiveView( + automationId: string, + organizationId: string, + ) { + const automation = await this.getRunnableAutomation({ + automationId, + organizationId, + }); + const profile = await this.profiles.resolveProfileForTarget({ + organizationId, + targetUrl: automation.targetUrl, + }); + const run = await this.runs.createRun({ + automationId, + profileId: profile.id, + }); + + try { + const { sessionId, liveViewUrl } = + await this.sessions.createSessionWithContext(profile.contextId); + return { runId: run.id, sessionId, liveViewUrl, profileId: profile.id }; + } catch (error) { + const result = failedBrowserEvidenceRunResult(error); + await this.runs.finishRun({ + runId: run.id, + startedAt: run.startedAt, + result, + }); + await this.applyProfileResult({ + organizationId, + profileId: profile.id, + result, + }); + throw error; + } + } + + async executeAutomationOnSession( + automationId: string, + runId: string, + sessionId: string, + organizationId: string, + ) { + const automation = await this.getRunnableAutomation({ + automationId, + organizationId, + }); + const run = await this.runs.getActiveRun({ + runId, + automationId, + }); + + const profile = await this.profiles.resolveProfileForTarget({ + organizationId, + targetUrl: automation.targetUrl, + profileId: run.profileId ?? undefined, + }); + let result: BrowserEvidenceRunResult; + try { + result = await this.runner.executeEvidenceOnSession({ + organizationId, + taskId: automation.taskId, + automationId, + runId, + sessionId, + targetUrl: automation.targetUrl, + instruction: automation.instruction, + evaluationCriteria: automation.evaluationCriteria, + profile: { + id: profile.id, + hostname: profile.hostname, + contextId: profile.contextId, + vaultProvider: profile.vaultProvider, + vaultExternalItemRef: profile.vaultExternalItemRef, + vaultConnectionId: profile.vaultConnectionId, + }, + beforeExecution: () => + this.runs.assertRunIsStillActive({ runId, automationId }), + }); + } catch (error) { + if (this.isTerminalReplayError(error)) throw error; + this.logger.error('Browser evidence runner failed', error); + result = failedBrowserEvidenceRunResult(error); + } + + await this.runs.finishRun({ runId, startedAt: run.startedAt, result }); + await this.applyProfileResult({ + organizationId, + profileId: profile.id, + result, + }); + return this.toRunResponse({ runId, result }); + } + + async runBrowserAutomation(automationId: string, organizationId: string) { + const automation = await this.getRunnableAutomation({ + automationId, + organizationId, + }); + const profile = await this.profiles.resolveProfileForTarget({ + organizationId, + targetUrl: automation.targetUrl, + }); + const run = await this.runs.createRun({ + automationId, + profileId: profile.id, + }); + + if (profile.status !== 'verified') { + const result = this.profileBlockedResult(profile.status); + await this.runs.finishRun({ + runId: run.id, + startedAt: run.startedAt, + result, + }); + return this.toRunResponse({ runId: run.id, result }); + } + + let result: BrowserEvidenceRunResult; + try { + result = await this.runner.runEvidence({ + organizationId, + taskId: automation.taskId, + automationId, + runId: run.id, + targetUrl: automation.targetUrl, + instruction: automation.instruction, + evaluationCriteria: automation.evaluationCriteria, + profile: { + id: profile.id, + hostname: profile.hostname, + contextId: profile.contextId, + vaultProvider: profile.vaultProvider, + vaultExternalItemRef: profile.vaultExternalItemRef, + vaultConnectionId: profile.vaultConnectionId, + }, + }); + } catch (error) { + this.logger.error('Browser evidence runner failed', error); + result = failedBrowserEvidenceRunResult(error); + } + await this.runs.finishRun({ + runId: run.id, + startedAt: run.startedAt, + result, + }); + await this.applyProfileResult({ + organizationId, + profileId: profile.id, + result, + }); + return this.toRunResponse({ runId: run.id, result }); + } + + private async applyProfileResult(input: { + organizationId: string; + profileId: string; + result: BrowserEvidenceRunResult; + }) { + if (input.result.failureCode === 'needs_reauth') { + await this.profiles.markNeedsReauth({ + organizationId: input.organizationId, + profileId: input.profileId, + reason: input.result.blockedReason, + }); + } + + if ( + input.result.failureCode === 'captcha_blocked' || + input.result.failureCode === 'needs_user_action' + ) { + await this.profiles.markBlocked({ + organizationId: input.organizationId, + profileId: input.profileId, + reason: + input.result.blockedReason ?? + input.result.error ?? + 'User action is required before this automation can run.', + }); + } + } + + private profileBlockedResult(status: string): BrowserEvidenceRunResult { + const needsUserAction = status === 'blocked'; + return { + success: false, + status: 'blocked', + error: needsUserAction + ? 'This browser profile is blocked. Resolve the blocked state before running automations.' + : 'This browser profile is not verified. Reconnect it before running automations.', + needsReauth: !needsUserAction, + failureCode: needsUserAction ? 'needs_user_action' : 'needs_reauth', + failureStage: 'auth', + blockedReason: needsUserAction + ? 'Browser profile is blocked.' + : 'Browser profile is not verified.', + logs: [], + }; + } + + private toRunResponse(input: { + runId: string; + result: BrowserEvidenceRunResult; + }) { + return { + runId: input.runId, + success: input.result.success, + screenshotUrl: input.result.screenshotUrl, + evaluationStatus: input.result.evaluationStatus, + evaluationReason: input.result.evaluationReason, + error: input.result.error, + needsReauth: input.result.needsReauth, + failureCode: input.result.failureCode, + failureStage: input.result.failureStage, + blockedReason: input.result.blockedReason, + }; + } + + private async getRunnableAutomation(input: { + automationId: string; + organizationId: string; + }) { + const automation = await db.browserAutomation.findUnique({ + where: { id: input.automationId }, + include: { + task: { + select: { title: true, description: true, organizationId: true }, + }, + }, + }); + if ( + !automation || + automation.task.organizationId !== input.organizationId + ) { + throw new NotFoundException('Automation not found'); + } + return automation; + } + + private isTerminalReplayError(error: unknown): boolean { + return ( + error instanceof ConflictException || error instanceof NotFoundException + ); + } +} diff --git a/apps/api/src/browserbase/browser-automation-run-result.ts b/apps/api/src/browserbase/browser-automation-run-result.ts new file mode 100644 index 0000000000..8740e92f03 --- /dev/null +++ b/apps/api/src/browserbase/browser-automation-run-result.ts @@ -0,0 +1,39 @@ +import { type Prisma } from '@db'; +import { + type BrowserAutomationFailureCode, + classifyBrowserAutomationError, +} from './browser-automation-errors'; +import type { BrowserEvidenceRunResult } from './browser-evidence-runner.service'; + +export function statusForBrowserFailureCode( + code: BrowserAutomationFailureCode | undefined, +): 'failed' | 'blocked' { + if (code === 'captcha_blocked' || code === 'needs_user_action') { + return 'blocked'; + } + return 'failed'; +} + +export function failedBrowserEvidenceRunResult( + error: unknown, +): BrowserEvidenceRunResult { + const classified = classifyBrowserAutomationError(error); + const logs: Prisma.InputJsonArray = [ + { + timestamp: new Date().toISOString(), + stage: classified.stage, + message: classified.userFacing, + }, + ]; + + return { + success: false, + status: statusForBrowserFailureCode(classified.code), + error: classified.userFacing, + needsReauth: classified.needsReauth, + failureCode: classified.code, + failureStage: classified.stage, + blockedReason: classified.blockedReason, + logs, + }; +} diff --git a/apps/api/src/browserbase/browser-automation-run-store.service.ts b/apps/api/src/browserbase/browser-automation-run-store.service.ts new file mode 100644 index 0000000000..ac02332299 --- /dev/null +++ b/apps/api/src/browserbase/browser-automation-run-store.service.ts @@ -0,0 +1,103 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db, Prisma } from '@db'; +import type { BrowserEvidenceRunResult } from './browser-evidence-runner.service'; + +@Injectable() +export class BrowserAutomationRunStoreService { + private readonly maxCreateRunAttempts = 3; + + async createRun(input: { automationId: string; profileId?: string }) { + for (let attempt = 0; attempt < this.maxCreateRunAttempts; attempt += 1) { + try { + return await db.$transaction( + async (tx) => { + const attemptCount = + (await tx.browserAutomationRun.count({ + where: { automationId: input.automationId }, + })) + 1; + return tx.browserAutomationRun.create({ + data: { + automationId: input.automationId, + profileId: input.profileId, + status: 'running', + startedAt: new Date(), + attemptCount, + }, + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + } catch (error) { + if ( + !this.isSerializationConflict(error) || + attempt === this.maxCreateRunAttempts - 1 + ) { + throw error; + } + } + } + + throw new Error('Failed to create browser automation run.'); + } + + async finishRun(input: { + runId: string; + startedAt: Date | null; + result: BrowserEvidenceRunResult; + }): Promise { + const updated = await db.browserAutomationRun.updateMany({ + where: { id: input.runId, status: 'running' }, + data: { + status: input.result.status, + completedAt: new Date(), + durationMs: input.startedAt + ? Date.now() - input.startedAt.getTime() + : 0, + screenshotUrl: input.result.screenshotKey, + evaluationStatus: input.result.evaluationStatus ?? null, + evaluationReason: input.result.evaluationReason ?? null, + error: input.result.error, + failureCode: input.result.failureCode, + failureStage: input.result.failureStage, + blockedReason: input.result.blockedReason, + finalUrl: input.result.finalUrl, + logs: input.result.logs, + }, + }); + + if (updated.count === 0) { + throw new ConflictException('Run is no longer active.'); + } + } + + async getActiveRun(input: { runId: string; automationId: string }) { + const run = await db.browserAutomationRun.findUnique({ + where: { id: input.runId }, + }); + if (!run || run.automationId !== input.automationId) { + throw new NotFoundException('Run not found'); + } + if (run.status !== 'running') { + throw new ConflictException('Run is no longer active.'); + } + return run; + } + + async assertRunIsStillActive(input: { + runId: string; + automationId: string; + }): Promise { + await this.getActiveRun(input); + } + + private isSerializationConflict(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034' + ); + } +} diff --git a/apps/api/src/browserbase/browser-evidence-evaluation.spec.ts b/apps/api/src/browserbase/browser-evidence-evaluation.spec.ts new file mode 100644 index 0000000000..df90d48ce4 --- /dev/null +++ b/apps/api/src/browserbase/browser-evidence-evaluation.spec.ts @@ -0,0 +1,41 @@ +import type { BrowserEvidenceLog } from './browser-evidence-execution'; +import { + type BrowserEvidenceEvaluator, + evaluateIfNeeded, +} from './browser-evidence-evaluation'; + +describe('evaluateIfNeeded', () => { + it('marks evaluation extraction failures as failed evaluations', async () => { + const logs: BrowserEvidenceLog[] = []; + const stagehand: BrowserEvidenceEvaluator = { + async extract() { + throw new Error('extract failed'); + }, + }; + + const result = await evaluateIfNeeded({ + stagehand, + criteria: 'Dashboard is visible', + logs, + }); + + expect(result).toEqual({ + success: false, + evaluationStatus: 'fail', + evaluationReason: + 'The automation captured evidence, but evaluation failed. Review the screenshot manually.', + error: + 'The automation captured evidence, but evaluation failed. Review the screenshot manually.', + failureCode: 'evaluation_failed', + failureStage: 'evaluation', + }); + expect(logs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + stage: 'evaluation', + message: 'extract failed', + }), + ]), + ); + }); +}); diff --git a/apps/api/src/browserbase/browser-evidence-evaluation.ts b/apps/api/src/browserbase/browser-evidence-evaluation.ts new file mode 100644 index 0000000000..bd378205ba --- /dev/null +++ b/apps/api/src/browserbase/browser-evidence-evaluation.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import { + type BrowserAutomationFailureCode, + type BrowserAutomationFailureStage, + evaluationFailedError, +} from './browser-automation-errors'; +import type { BrowserEvidenceLog } from './browser-evidence-execution'; + +export interface BrowserEvidenceEvaluator { + extract( + instruction: string, + schema: T, + ): Promise>; +} + +export async function evaluateIfNeeded({ + stagehand, + criteria, + logs, +}: { + stagehand: BrowserEvidenceEvaluator; + criteria?: string | null; + logs: BrowserEvidenceLog[]; +}): Promise<{ + success: boolean; + evaluationStatus?: 'pass' | 'fail'; + evaluationReason?: string; + error?: string; + failureCode?: BrowserAutomationFailureCode; + failureStage?: BrowserAutomationFailureStage; +}> { + const normalizedCriteria = criteria?.trim(); + if (!normalizedCriteria) return { success: true }; + + logs.push({ + timestamp: new Date().toISOString(), + stage: 'evaluation', + message: 'Evaluating final page against criteria.', + }); + + try { + const evalSchema = z.object({ + pass: z.boolean(), + reason: z.string(), + }); + const evaluation = await stagehand.extract( + [ + 'You are an auditor reviewing the current page after an automation has finished navigating.', + 'Decide whether the page clearly satisfies this criteria.', + 'Only return pass=true if the evidence is unambiguously present and visible.', + 'If it is ambiguous, missing, or contradicted, return pass=false.', + 'Always provide a short reason (max 220 characters).', + '', + `Criteria: ${normalizedCriteria}`, + ].join('\n'), + evalSchema, + ); + + return { + success: true, + evaluationStatus: evaluation.pass ? 'pass' : 'fail', + evaluationReason: evaluation.reason, + }; + } catch (err) { + const classified = evaluationFailedError( + 'The automation captured evidence, but evaluation failed. Review the screenshot manually.', + ); + logs.push({ + timestamp: new Date().toISOString(), + stage: classified.stage, + message: err instanceof Error ? err.message : String(err), + }); + return { + success: false, + evaluationStatus: 'fail', + evaluationReason: classified.userFacing, + error: classified.userFacing, + failureCode: classified.code, + failureStage: classified.stage, + }; + } +} diff --git a/apps/api/src/browserbase/browser-evidence-execution.ts b/apps/api/src/browserbase/browser-evidence-execution.ts new file mode 100644 index 0000000000..85cc9c6560 --- /dev/null +++ b/apps/api/src/browserbase/browser-evidence-execution.ts @@ -0,0 +1,224 @@ +import { Logger } from '@nestjs/common'; +import { z } from 'zod'; +import { renderOverlay } from './screenshot-overlay'; +import type { BrowserbaseSessionService } from './browserbase-session.service'; +import { + bringEvidencePageToFront, + resolveEvidencePage, +} from './browser-evidence-page'; +import { evaluateIfNeeded } from './browser-evidence-evaluation'; +import { + type BrowserAutomationFailureCode, + type BrowserAutomationFailureStage, + type ClassifiedBrowserAutomationError, + classifyBrowserAutomationError, +} from './browser-automation-errors'; +import type { BrowserEvidenceSessionInput } from './browser-evidence-runner.service'; + +type Stagehand = import('@browserbasehq/stagehand').Stagehand; + +const STAGEHAND_CUA_MODEL = 'anthropic/claude-sonnet-4-6'; +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export interface BrowserEvidenceLog { + timestamp: string; + stage: string; + message: string; +} + +export interface BrowserEvidenceExecutionResult { + success: boolean; + screenshot?: string; + finalUrl?: string; + evaluationStatus?: 'pass' | 'fail'; + evaluationReason?: string; + error?: string; + needsReauth?: boolean; + failureCode?: BrowserAutomationFailureCode; + failureStage?: BrowserAutomationFailureStage; + blockedReason?: string; + logs: BrowserEvidenceLog[]; +} + +export async function executeBrowserEvidence({ + input, + sessions, + logger, +}: { + input: BrowserEvidenceSessionInput; + sessions: BrowserbaseSessionService; + logger: Logger; +}): Promise { + const logs: BrowserEvidenceLog[] = []; + const log = (stage: string, message: string) => { + logs.push({ timestamp: new Date().toISOString(), stage, message }); + }; + let stagehand: Stagehand | null = null; + let currentStage: BrowserAutomationFailureStage = 'session'; + + try { + log('session', 'Initializing Stagehand session.'); + stagehand = await sessions.createStagehand(input.sessionId); + const initialPage = await sessions.ensureActivePage(stagehand); + let page = initialPage; + + currentStage = 'navigation'; + log('navigation', `Opening ${input.targetUrl}.`); + await page.goto(input.targetUrl, { + waitUntil: 'domcontentloaded', + timeoutMs: 30000, + }); + await delay(1000); + + currentStage = 'auth'; + const authCheck = await checkAuth(stagehand); + + if (!authCheck.isLoggedIn) { + const classified = classifyBrowserAutomationError( + new Error('Session expired. User is not logged in.'), + 'auth', + ); + log('auth', classified.userFacing); + return toExecutionFailure({ classified, logs }); + } + + currentStage = 'action'; + log('action', 'Running navigation instruction.'); + const instruction = `${input.instruction}. After completing all navigation steps, stop and wait.`; + await stagehand + .agent({ + cua: true, + model: { + modelName: STAGEHAND_CUA_MODEL, + apiKey: process.env.ANTHROPIC_API_KEY, + }, + }) + .execute({ instruction, maxSteps: 20 }); + + await delay(2000); + page = await resolveEvidencePage({ + stagehand, + initialPage, + targetUrl: input.targetUrl, + }); + const finalUrl = page.url(); + + currentStage = 'screenshot'; + log('screenshot', 'Capturing screenshot.'); + const rawScreenshot = await page.screenshot({ + type: 'jpeg', + quality: 80, + fullPage: false, + }); + + const screenshot = await renderScreenshot({ + logger, + logs, + rawScreenshot, + instruction: input.instruction, + finalUrl, + }); + currentStage = 'evaluation'; + await bringEvidencePageToFront(page); + const evaluation = await evaluateIfNeeded({ + stagehand, + criteria: input.evaluationCriteria, + logs, + }); + + if (!evaluation.success) { + return { + success: false, + screenshot, + finalUrl, + evaluationStatus: evaluation.evaluationStatus, + evaluationReason: evaluation.evaluationReason, + error: evaluation.error, + failureCode: evaluation.failureCode, + failureStage: evaluation.failureStage, + logs, + }; + } + + return { + success: true, + screenshot, + finalUrl, + evaluationStatus: evaluation.evaluationStatus, + evaluationReason: evaluation.evaluationReason, + logs, + }; + } catch (err) { + const classified = classifyBrowserAutomationError(err, currentStage); + log(classified.stage, classified.userFacing); + logger.error('Failed to execute browser evidence run', err); + return toExecutionFailure({ classified, logs }); + } finally { + if (stagehand) { + await sessions.safeCloseStagehand(stagehand); + } + } +} + +async function renderScreenshot({ + logger, + logs, + rawScreenshot, + instruction, + finalUrl, +}: { + logger: Logger; + logs: BrowserEvidenceLog[]; + rawScreenshot: Buffer; + instruction: string; + finalUrl: string; +}): Promise { + let finalBuffer: Buffer = rawScreenshot; + try { + finalBuffer = await renderOverlay({ + buffer: rawScreenshot, + instruction, + sourceUrl: finalUrl, + capturedAt: new Date(), + }); + } catch (overlayErr) { + logger.warn('Screenshot overlay render failed; uploading raw image', { + error: + overlayErr instanceof Error ? overlayErr.message : String(overlayErr), + }); + logs.push({ + timestamp: new Date().toISOString(), + stage: 'screenshot', + message: 'Overlay failed; captured raw screenshot.', + }); + } + return finalBuffer.toString('base64'); +} + +async function checkAuth( + stagehand: Stagehand, +): Promise<{ isLoggedIn: boolean }> { + const loginSchema = z.object({ isLoggedIn: z.boolean() }); + return stagehand.extract( + 'Check if the user is logged in to this website. Look for a user avatar, profile menu, account dropdown, or login/sign-in buttons. Return true if logged in, false if you see login buttons or a login form.', + loginSchema, + ); +} + +function toExecutionFailure({ + classified, + logs, +}: { + classified: ClassifiedBrowserAutomationError; + logs: BrowserEvidenceLog[]; +}): BrowserEvidenceExecutionResult { + return { + success: false, + error: classified.userFacing, + needsReauth: classified.needsReauth, + failureCode: classified.code, + failureStage: classified.stage, + blockedReason: classified.blockedReason, + logs, + }; +} diff --git a/apps/api/src/browserbase/browser-evidence-page.spec.ts b/apps/api/src/browserbase/browser-evidence-page.spec.ts new file mode 100644 index 0000000000..df155bfeaa --- /dev/null +++ b/apps/api/src/browserbase/browser-evidence-page.spec.ts @@ -0,0 +1,62 @@ +import { selectEvidencePage } from './browser-evidence-page'; + +interface TestPage { + closed: boolean; + id: string; + url(): string; +} + +const page = ({ + closed = false, + id, + url, +}: { + closed?: boolean; + id: string; + url: string; +}): TestPage => ({ + closed, + id, + url: () => url, +}); + +const isClosed = (testPage: TestPage) => testPage.closed; + +describe('selectEvidencePage', () => { + it('prefers the newest open same-host page over the original page', () => { + const initialPage = page({ + id: 'initial', + url: 'https://github.com/acme/start', + }); + const newTab = page({ + id: 'new-tab', + url: 'https://github.com/acme/evidence', + }); + + const selected = selectEvidencePage({ + pages: [initialPage, newTab], + initialPage, + targetUrl: 'https://github.com/acme/start', + isClosed, + }); + + expect(selected).toBe(newTab); + }); + + it('returns null when every page is closed', () => { + const initialPage = page({ + closed: true, + id: 'initial', + url: 'https://github.com/acme/start', + }); + + const selected = selectEvidencePage({ + pages: [initialPage], + initialPage, + targetUrl: 'https://github.com/acme/start', + isClosed, + }); + + expect(selected).toBeNull(); + }); +}); diff --git a/apps/api/src/browserbase/browser-evidence-page.ts b/apps/api/src/browserbase/browser-evidence-page.ts new file mode 100644 index 0000000000..ee12e64d9f --- /dev/null +++ b/apps/api/src/browserbase/browser-evidence-page.ts @@ -0,0 +1,113 @@ +import type { BrowserbaseSessionService } from './browserbase-session.service'; +import { normalizeHostnameFromUrl } from './browserbase-url'; + +type Stagehand = import('@browserbasehq/stagehand').Stagehand; +type EvidencePage = Awaited< + ReturnType +>; + +interface EvidencePageCandidate { + url(): string; +} + +export async function resolveEvidencePage({ + stagehand, + initialPage, + targetUrl, +}: { + stagehand: Stagehand; + initialPage: EvidencePage; + targetUrl: string; +}): Promise { + const selectedPage = selectEvidencePage({ + pages: stagehand.context.pages(), + initialPage, + targetUrl, + isClosed: isEvidencePageClosed, + }); + + if (!selectedPage) { + throw new Error('Browser session ended before evidence capture.'); + } + + await bringEvidencePageToFront(selectedPage); + return selectedPage; +} + +export function selectEvidencePage({ + pages, + initialPage, + targetUrl, + isClosed, +}: { + pages: Page[]; + initialPage: Page; + targetUrl: string; + isClosed: (page: Page) => boolean; +}): Page | null { + const openPages = pages.filter((page) => !isClosed(page)); + const targetHostname = safeHostname(targetUrl); + const openMatchesTarget = (page: Page) => + targetHostname !== null && safeHostname(page.url()) === targetHostname; + + const newestMatchingNewPage = [...openPages] + .reverse() + .find((page) => page !== initialPage && openMatchesTarget(page)); + if (newestMatchingNewPage) return newestMatchingNewPage; + + if (!isClosed(initialPage)) return initialPage; + + const newestMatchingPage = [...openPages].reverse().find(openMatchesTarget); + if (newestMatchingPage) return newestMatchingPage; + + return openPages.at(-1) ?? null; +} + +export async function bringEvidencePageToFront( + page: EvidencePage, +): Promise { + const focusable = getFocusablePage(page); + if (focusable) await focusable.bringToFront(); +} + +function isEvidencePageClosed(page: EvidencePage): boolean { + const closable = getClosablePage(page); + return closable ? closable.isClosed() : false; +} + +function getClosablePage( + page: EvidencePage, +): { isClosed: () => boolean } | null { + if (!hasMethod(page, 'isClosed')) return null; + return { isClosed: () => Boolean(page.isClosed()) }; +} + +function getFocusablePage( + page: EvidencePage, +): { bringToFront: () => Promise } | null { + if (!hasMethod(page, 'bringToFront')) return null; + return { + bringToFront: async () => { + await page.bringToFront(); + }, + }; +} + +function hasMethod( + value: unknown, + key: K, +): value is Record unknown> { + if (typeof value !== 'object' || value === null || !(key in value)) { + return false; + } + const record = value as Record; + return typeof record[key] === 'function'; +} + +function safeHostname(url: string): string | null { + try { + return normalizeHostnameFromUrl(url); + } catch { + return null; + } +} diff --git a/apps/api/src/browserbase/browser-evidence-runner.service.spec.ts b/apps/api/src/browserbase/browser-evidence-runner.service.spec.ts new file mode 100644 index 0000000000..1f615889d4 --- /dev/null +++ b/apps/api/src/browserbase/browser-evidence-runner.service.spec.ts @@ -0,0 +1,101 @@ +import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; +import { executeBrowserEvidence } from './browser-evidence-execution'; +import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; +import { BrowserbaseSessionService } from './browserbase-session.service'; + +jest.mock('@db', () => ({ + db: {}, + Prisma: {}, +})); + +jest.mock('./browser-evidence-execution', () => ({ + executeBrowserEvidence: jest.fn(), +})); + +jest.mock('@/app/s3', () => ({ + BUCKET_NAME: 'test-bucket', + getSignedUrl: jest.fn(), + s3Client: { send: jest.fn() }, +})); + +describe('BrowserEvidenceRunnerService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('treats screenshot upload failures as non-fatal', async () => { + const screenshots = new BrowserbaseScreenshotService(); + jest + .spyOn(screenshots, 'uploadScreenshot') + .mockRejectedValue(new Error('S3 unavailable')); + + jest.mocked(executeBrowserEvidence).mockResolvedValue({ + success: true, + screenshot: 'base64-image', + finalUrl: 'https://example.com/final', + logs: [], + }); + + const service = new BrowserEvidenceRunnerService( + new BrowserbaseSessionService(), + screenshots, + ); + + const result = await service.executeEvidenceOnSession({ + organizationId: 'org_1', + taskId: 'tsk_1', + automationId: 'bau_1', + runId: 'bar_1', + targetUrl: 'https://example.com', + instruction: 'collect evidence', + profile: { + id: 'bap_1', + hostname: 'example.com', + contextId: 'ctx_1', + }, + sessionId: 'sess_1', + }); + + expect(result.status).toBe('completed'); + expect(result.screenshotKey).toBeUndefined(); + expect(result.logs).toEqual([ + expect.objectContaining({ + stage: 'upload', + message: 'Screenshot upload failed; run completed without screenshot.', + }), + ]); + }); + + it('does not pass vault credential material into evidence execution', async () => { + jest.mocked(executeBrowserEvidence).mockResolvedValue({ + success: true, + finalUrl: 'https://example.com/final', + logs: [], + }); + + const service = new BrowserEvidenceRunnerService( + new BrowserbaseSessionService(), + new BrowserbaseScreenshotService(), + ); + + await service.executeEvidenceOnSession({ + organizationId: 'org_1', + automationId: 'bau_1', + runId: 'bar_1', + targetUrl: 'https://example.com', + instruction: 'collect evidence', + profile: { + id: 'bap_1', + hostname: 'example.com', + contextId: 'ctx_1', + vaultProvider: '1password', + vaultExternalItemRef: 'op://vault/item', + vaultConnectionId: 'conn_1', + }, + sessionId: 'sess_1', + }); + + const call = jest.mocked(executeBrowserEvidence).mock.calls[0]; + expect(call?.[0].input).not.toHaveProperty('credentialMaterial'); + }); +}); diff --git a/apps/api/src/browserbase/browser-evidence-runner.service.ts b/apps/api/src/browserbase/browser-evidence-runner.service.ts new file mode 100644 index 0000000000..0e07b19582 --- /dev/null +++ b/apps/api/src/browserbase/browser-evidence-runner.service.ts @@ -0,0 +1,206 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma } from '@db'; +import { BrowserbaseSessionService } from './browserbase-session.service'; +import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; +import { + type BrowserAutomationFailureCode, + type BrowserAutomationFailureStage, +} from './browser-automation-errors'; +import { + executeBrowserEvidence, + type BrowserEvidenceLog, + type BrowserEvidenceExecutionResult, +} from './browser-evidence-execution'; +import { browserRunCoordinator } from './browser-run-coordinator'; + +export interface BrowserEvidenceRunnerInput { + organizationId: string; + taskId?: string; + automationId: string; + runId: string; + targetUrl: string; + instruction: string; + evaluationCriteria?: string | null; + profile: { + id: string; + hostname: string; + contextId: string; + vaultProvider?: string | null; + vaultExternalItemRef?: string | null; + vaultConnectionId?: string | null; + }; + beforeExecution?: () => Promise; +} + +export interface BrowserEvidenceSessionInput extends BrowserEvidenceRunnerInput { + sessionId: string; +} + +export interface BrowserEvidenceRunResult { + success: boolean; + status: 'completed' | 'failed' | 'blocked'; + screenshotKey?: string; + screenshotUrl?: string; + finalUrl?: string; + evaluationStatus?: 'pass' | 'fail'; + evaluationReason?: string; + error?: string; + needsReauth?: boolean; + failureCode?: BrowserAutomationFailureCode; + failureStage?: BrowserAutomationFailureStage; + blockedReason?: string; + logs: Prisma.InputJsonValue; +} + +const toJsonLogs = (logs: BrowserEvidenceLog[]): Prisma.InputJsonArray => + logs.map( + (log): Prisma.InputJsonObject => ({ + timestamp: log.timestamp, + stage: log.stage, + message: log.message, + }), + ); + +@Injectable() +export class BrowserEvidenceRunnerService { + private readonly logger = new Logger(BrowserEvidenceRunnerService.name); + + constructor( + private readonly sessions: BrowserbaseSessionService = new BrowserbaseSessionService(), + private readonly screenshots: BrowserbaseScreenshotService = new BrowserbaseScreenshotService(), + ) {} + + async runEvidence( + input: BrowserEvidenceRunnerInput, + ): Promise { + return browserRunCoordinator.withProfileLock({ + profileId: input.profile.id, + hostname: input.profile.hostname, + run: async () => { + const { sessionId } = await this.sessions.createSessionWithContext( + input.profile.contextId, + ); + + try { + return await this.executeEvidenceOnSessionUnlocked({ + ...input, + sessionId, + }); + } finally { + await this.closeSession(sessionId); + } + }, + }); + } + + async executeEvidenceOnSession( + input: BrowserEvidenceSessionInput, + ): Promise { + return browserRunCoordinator.withProfileLock({ + profileId: input.profile.id, + hostname: input.profile.hostname, + run: () => this.executeEvidenceOnSessionUnlocked(input), + }); + } + + private async executeEvidenceOnSessionUnlocked( + input: BrowserEvidenceSessionInput, + ): Promise { + await input.beforeExecution?.(); + + const execution = await executeBrowserEvidence({ + input, + sessions: this.sessions, + logger: this.logger, + }); + let uploaded: { screenshotKey: string; screenshotUrl: string } | null = + null; + try { + uploaded = await this.uploadCapturedScreenshot({ input, execution }); + } catch (err) { + this.logger.warn( + 'Screenshot upload failed; continuing without screenshot', + { + runId: input.runId, + error: err instanceof Error ? err.message : String(err), + }, + ); + execution.logs.push({ + timestamp: new Date().toISOString(), + stage: 'upload', + message: 'Screenshot upload failed; run completed without screenshot.', + }); + } + + if (!execution.success) { + return { + success: false, + status: this.blockedStatusForCode(execution.failureCode), + screenshotKey: uploaded?.screenshotKey, + screenshotUrl: uploaded?.screenshotUrl, + finalUrl: execution.finalUrl, + evaluationStatus: execution.evaluationStatus, + evaluationReason: execution.evaluationReason, + error: execution.error, + needsReauth: execution.needsReauth, + failureCode: execution.failureCode, + failureStage: execution.failureStage, + blockedReason: execution.blockedReason, + logs: toJsonLogs(execution.logs), + }; + } + + return { + success: true, + status: 'completed', + screenshotKey: uploaded?.screenshotKey, + screenshotUrl: uploaded?.screenshotUrl, + finalUrl: execution.finalUrl, + evaluationStatus: execution.evaluationStatus, + evaluationReason: execution.evaluationReason, + logs: toJsonLogs(execution.logs), + }; + } + + private async uploadCapturedScreenshot({ + input, + execution, + }: { + input: BrowserEvidenceRunnerInput; + execution: BrowserEvidenceExecutionResult; + }): Promise<{ screenshotKey: string; screenshotUrl: string } | null> { + if (!execution.screenshot) return null; + + const screenshotKey = await this.screenshots.uploadScreenshot({ + organizationId: input.organizationId, + automationId: input.automationId, + runId: input.runId, + base64Screenshot: execution.screenshot, + }); + const screenshotUrl = await this.screenshots.getPresignedUrl({ + key: screenshotKey, + }); + + return { screenshotKey, screenshotUrl }; + } + + private async closeSession(sessionId: string): Promise { + try { + await this.sessions.closeSession(sessionId); + } catch (err) { + this.logger.warn('Failed to close Browserbase session (ignored)', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + private blockedStatusForCode( + code: BrowserAutomationFailureCode | undefined, + ): 'failed' | 'blocked' { + if (code === 'captcha_blocked' || code === 'needs_user_action') { + return 'blocked'; + } + return 'failed'; + } +} diff --git a/apps/api/src/browserbase/browser-run-coordinator.spec.ts b/apps/api/src/browserbase/browser-run-coordinator.spec.ts new file mode 100644 index 0000000000..4350a0716d --- /dev/null +++ b/apps/api/src/browserbase/browser-run-coordinator.spec.ts @@ -0,0 +1,71 @@ +import { BrowserRunCoordinator } from './browser-run-coordinator'; + +describe('BrowserRunCoordinator', () => { + it('serializes runs for the same profile', async () => { + process.env.BROWSER_AUTOMATION_DOMAIN_THROTTLE_MS = '1'; + const coordinator = new BrowserRunCoordinator(); + const events: string[] = []; + let releaseFirst: () => void = () => {}; + + const first = coordinator.withProfileLock({ + profileId: 'bap_1', + hostname: 'example.com', + run: async () => { + events.push('first:start'); + await new Promise((resolve) => { + releaseFirst = resolve; + }); + events.push('first:end'); + }, + }); + + const second = coordinator.withProfileLock({ + profileId: 'bap_1', + hostname: 'example.com', + run: async () => { + events.push('second:start'); + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(events).toEqual(['first:start']); + + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual(['first:start', 'first:end', 'second:start']); + }); + + it('serializes runs for the same hostname across profiles', async () => { + process.env.BROWSER_AUTOMATION_DOMAIN_THROTTLE_MS = '1'; + const coordinator = new BrowserRunCoordinator(); + const events: string[] = []; + let releaseFirst: () => void = () => {}; + + const first = coordinator.withProfileLock({ + profileId: 'bap_1', + hostname: 'example.com', + run: async () => { + events.push('first:start'); + await new Promise((resolve) => { + releaseFirst = resolve; + }); + events.push('first:end'); + }, + }); + + const second = coordinator.withProfileLock({ + profileId: 'bap_2', + hostname: 'example.com', + run: async () => { + events.push('second:start'); + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(events).toEqual(['first:start']); + + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual(['first:start', 'first:end', 'second:start']); + }); +}); diff --git a/apps/api/src/browserbase/browser-run-coordinator.ts b/apps/api/src/browserbase/browser-run-coordinator.ts new file mode 100644 index 0000000000..a141386db8 --- /dev/null +++ b/apps/api/src/browserbase/browser-run-coordinator.ts @@ -0,0 +1,93 @@ +const DEFAULT_DOMAIN_THROTTLE_MS = 5_000; + +const parsePositiveInt = ( + value: string | undefined, + fallback: number, +): number => { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class BrowserRunCoordinator { + private readonly profileLocks = new Map>(); + private readonly domainLocks = new Map>(); + private readonly lastDomainRunAt = new Map(); + + async withProfileLock({ + profileId, + hostname, + run, + }: { + profileId: string; + hostname: string; + run: () => Promise; + }): Promise { + const previous = this.profileLocks.get(profileId) ?? Promise.resolve(); + let release: () => void = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + const chained = previous.then(() => current); + this.profileLocks.set(profileId, chained); + + await previous; + + try { + return await this.withDomainTurn({ hostname, run }); + } finally { + release(); + if (this.profileLocks.get(profileId) === chained) { + this.profileLocks.delete(profileId); + } + } + } + + private async withDomainTurn({ + hostname, + run, + }: { + hostname: string; + run: () => Promise; + }): Promise { + const previous = this.domainLocks.get(hostname) ?? Promise.resolve(); + let release: () => void = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + const chained = previous.then(() => current); + this.domainLocks.set(hostname, chained); + + await previous; + + try { + await this.waitForDomainTurn(hostname); + return await run(); + } finally { + release(); + if (this.domainLocks.get(hostname) === chained) { + this.domainLocks.delete(hostname); + } + } + } + + private async waitForDomainTurn(hostname: string): Promise { + const throttleMs = parsePositiveInt( + process.env.BROWSER_AUTOMATION_DOMAIN_THROTTLE_MS, + DEFAULT_DOMAIN_THROTTLE_MS, + ); + const now = Date.now(); + const lastRunAt = this.lastDomainRunAt.get(hostname) ?? 0; + const waitMs = Math.max(0, lastRunAt + throttleMs - now); + + if (waitMs > 0) { + await delay(waitMs); + } + + this.lastDomainRunAt.set(hostname, Date.now()); + } +} + +export const browserRunCoordinator = new BrowserRunCoordinator(); diff --git a/apps/api/src/browserbase/browserbase-org-context.service.spec.ts b/apps/api/src/browserbase/browserbase-org-context.service.spec.ts new file mode 100644 index 0000000000..f63756e3e7 --- /dev/null +++ b/apps/api/src/browserbase/browserbase-org-context.service.spec.ts @@ -0,0 +1,126 @@ +import { RequestTimeoutException } from '@nestjs/common'; +import { db } from '@db'; +import { BrowserbaseSessionService } from './browserbase-session.service'; +import { + BrowserbaseOrgContextService, + PENDING_CONTEXT_ID, +} from './browserbase-org-context.service'; + +jest.mock('@db', () => ({ + db: { + browserbaseContext: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + deleteMany: jest.fn(), + }, + }, +})); + +describe('BrowserbaseOrgContextService', () => { + let sessions: BrowserbaseSessionService; + let service: BrowserbaseOrgContextService; + + beforeEach(() => { + jest.clearAllMocks(); + sessions = new BrowserbaseSessionService(); + jest + .spyOn(sessions, 'createBrowserbaseContext') + .mockResolvedValue('ctx_new'); + service = new BrowserbaseOrgContextService(sessions); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('throws a request timeout when pending context creation never resolves', async () => { + jest.useFakeTimers(); + const now = new Date('2026-01-01T00:00:00.000Z'); + jest.setSystemTime(now); + (db.browserbaseContext.findUnique as jest.Mock).mockResolvedValue({ + organizationId: 'org_1', + contextId: PENDING_CONTEXT_ID, + updatedAt: now, + }); + + const promise = service.getOrCreateOrgContext('org_1'); + const expectation = expect(promise).rejects.toBeInstanceOf( + RequestTimeoutException, + ); + await jest.advanceTimersByTimeAsync(10_500); + + await expectation; + expect(db.browserbaseContext.updateMany).not.toHaveBeenCalled(); + }); + + it('keeps one timeout budget when a missing context row restarts the wait path', async () => { + jest.useFakeTimers(); + const now = new Date('2026-01-01T00:00:00.000Z'); + jest.setSystemTime(now); + (db.browserbaseContext.findUnique as jest.Mock) + .mockResolvedValueOnce({ + organizationId: 'org_1', + contextId: PENDING_CONTEXT_ID, + updatedAt: now, + }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue({ + organizationId: 'org_1', + contextId: PENDING_CONTEXT_ID, + updatedAt: now, + }); + (db.browserbaseContext.create as jest.Mock).mockRejectedValue({ + code: 'P2002', + }); + + const promise = service.getOrCreateOrgContext('org_1'); + const expectation = expect(promise).rejects.toBeInstanceOf( + RequestTimeoutException, + ); + await jest.advanceTimersByTimeAsync(10_500); + + await expectation; + expect(db.browserbaseContext.create).toHaveBeenCalledTimes(1); + }); + + it('claims and recovers stale pending context rows', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-01-01T00:02:00.000Z')); + (db.browserbaseContext.findUnique as jest.Mock).mockResolvedValue({ + organizationId: 'org_1', + contextId: PENDING_CONTEXT_ID, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + (db.browserbaseContext.updateMany as jest.Mock) + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 1 }); + + const result = await service.getOrCreateOrgContext('org_1'); + + expect(result).toEqual({ contextId: 'ctx_new', isNew: true }); + expect(db.browserbaseContext.create).not.toHaveBeenCalled(); + expect(db.browserbaseContext.updateMany).toHaveBeenCalledTimes(2); + expect(db.browserbaseContext.updateMany).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: expect.objectContaining({ + organizationId: 'org_1', + contextId: PENDING_CONTEXT_ID, + updatedAt: { lte: new Date('2026-01-01T00:01:00.000Z') }, + }), + data: expect.objectContaining({ + contextId: expect.stringMatching(/^__PENDING__:/), + }), + }), + ); + const claimId = (db.browserbaseContext.updateMany as jest.Mock).mock + .calls[0][0].data.contextId; + expect(db.browserbaseContext.updateMany).toHaveBeenNthCalledWith(2, { + where: { organizationId: 'org_1', contextId: claimId }, + data: { contextId: 'ctx_new' }, + }); + }); +}); diff --git a/apps/api/src/browserbase/browserbase-org-context.service.ts b/apps/api/src/browserbase/browserbase-org-context.service.ts new file mode 100644 index 0000000000..053ca313bd --- /dev/null +++ b/apps/api/src/browserbase/browserbase-org-context.service.ts @@ -0,0 +1,225 @@ +import { + ConflictException, + Injectable, + Logger, + RequestTimeoutException, +} from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { db } from '@db'; +import { BrowserbaseSessionService } from './browserbase-session.service'; + +export const PENDING_CONTEXT_ID = '__PENDING__'; +const PENDING_CONTEXT_PREFIX = `${PENDING_CONTEXT_ID}:`; +const ORG_CONTEXT_MAX_WAIT_MS = 10_000; +const ORG_CONTEXT_POLL_MS = 200; +const ORG_CONTEXT_STALE_MS = 60_000; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const isPrismaUniqueConstraintError = (error: unknown): boolean => { + if (typeof error !== 'object' || error === null) return false; + if (!('code' in error)) return false; + const code = (error as { code?: unknown }).code; + return code === 'P2002'; +}; + +const isPendingContextId = (contextId: string): boolean => + contextId === PENDING_CONTEXT_ID || + contextId.startsWith(PENDING_CONTEXT_PREFIX); + +@Injectable() +export class BrowserbaseOrgContextService { + private readonly logger = new Logger(BrowserbaseOrgContextService.name); + + constructor( + private readonly sessions: BrowserbaseSessionService = new BrowserbaseSessionService(), + ) {} + + async getOrCreateOrgContext( + organizationId: string, + ): Promise<{ contextId: string; isNew: boolean }> { + return this.getOrCreateOrgContextWithinDeadline({ + organizationId, + deadlineMs: Date.now() + ORG_CONTEXT_MAX_WAIT_MS, + }); + } + + private async getOrCreateOrgContextWithinDeadline(input: { + organizationId: string; + deadlineMs: number; + }): Promise<{ contextId: string; isNew: boolean }> { + if (Date.now() >= input.deadlineMs) { + this.throwContextTimeout(input.organizationId); + } + + const existing = await db.browserbaseContext.findUnique({ + where: { organizationId: input.organizationId }, + }); + + if (existing && !isPendingContextId(existing.contextId)) { + return { contextId: existing.contextId, isNew: false }; + } + + if (existing) { + const claimId = await this.claimStalePendingOrgContext({ + organizationId: input.organizationId, + contextId: existing.contextId, + updatedAt: existing.updatedAt, + }); + if (claimId) { + return this.createAndStoreOrgContext({ + organizationId: input.organizationId, + pendingContextId: claimId, + }); + } + return this.waitForOrgContext(input); + } + + try { + await db.browserbaseContext.create({ + data: { + organizationId: input.organizationId, + contextId: PENDING_CONTEXT_ID, + }, + }); + } catch (error) { + if (!isPrismaUniqueConstraintError(error)) throw error; + return this.waitForOrgContext(input); + } + + return this.createAndStoreOrgContext({ + organizationId: input.organizationId, + pendingContextId: PENDING_CONTEXT_ID, + }); + } + + async getOrgContext( + organizationId: string, + ): Promise<{ contextId: string } | null> { + const context = await db.browserbaseContext.findUnique({ + where: { organizationId }, + }); + if (!context || isPendingContextId(context.contextId)) return null; + return { contextId: context.contextId }; + } + + private async createAndStoreOrgContext(input: { + organizationId: string; + pendingContextId: string; + }): Promise<{ contextId: string; isNew: boolean }> { + try { + const contextId = await this.sessions.createBrowserbaseContext(); + const updated = await db.browserbaseContext.updateMany({ + where: { + organizationId: input.organizationId, + contextId: input.pendingContextId, + }, + data: { contextId }, + }); + if (updated.count !== 1) { + throw new ConflictException( + 'Browser context initialization was superseded. Please retry.', + ); + } + return { contextId, isNew: true }; + } catch (error) { + await this.clearPendingOrgContext({ + organizationId: input.organizationId, + pendingContextId: input.pendingContextId, + }); + throw error; + } + } + + private async waitForOrgContext(input: { + organizationId: string; + deadlineMs: number; + }): Promise<{ contextId: string; isNew: boolean }> { + while (Date.now() < input.deadlineMs) { + const current = await db.browserbaseContext.findUnique({ + where: { organizationId: input.organizationId }, + }); + + if (current && !isPendingContextId(current.contextId)) { + return { contextId: current.contextId, isNew: false }; + } + + if (!current) { + return await this.getOrCreateOrgContextWithinDeadline(input); + } + + const claimId = await this.claimStalePendingOrgContext({ + organizationId: input.organizationId, + contextId: current.contextId, + updatedAt: current.updatedAt, + }); + if (claimId) { + return this.createAndStoreOrgContext({ + organizationId: input.organizationId, + pendingContextId: claimId, + }); + } + await delay( + Math.min( + ORG_CONTEXT_POLL_MS, + Math.max(0, input.deadlineMs - Date.now()), + ), + ); + } + + this.throwContextTimeout(input.organizationId); + } + + private throwContextTimeout(organizationId: string): never { + this.logger.warn( + `Timed out waiting for Browserbase context creation for org ${organizationId}`, + ); + throw new RequestTimeoutException( + 'Browser context initialization is taking too long. Please retry.', + ); + } + + private async claimStalePendingOrgContext(input: { + organizationId: string; + contextId: string; + updatedAt: Date; + }): Promise { + const staleBefore = new Date(Date.now() - ORG_CONTEXT_STALE_MS); + if (input.updatedAt > staleBefore) return null; + + const claimId = `${PENDING_CONTEXT_PREFIX}${randomUUID()}`; + const updated = await db.browserbaseContext.updateMany({ + where: { + organizationId: input.organizationId, + contextId: input.contextId, + updatedAt: { lte: staleBefore }, + }, + data: { contextId: claimId }, + }); + + if (updated.count !== 1) return null; + this.logger.warn( + `Recovering stale Browserbase context initialization for org ${input.organizationId}`, + ); + return claimId; + } + + private async clearPendingOrgContext(input: { + organizationId: string; + pendingContextId: string; + }): Promise { + try { + await db.browserbaseContext.deleteMany({ + where: { + organizationId: input.organizationId, + contextId: input.pendingContextId, + }, + }); + } catch (error) { + this.logger.warn('Failed to clear pending Browserbase context', { + organizationId: input.organizationId, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/apps/api/src/browserbase/browserbase-screenshot.service.ts b/apps/api/src/browserbase/browserbase-screenshot.service.ts new file mode 100644 index 0000000000..9f430ad4c7 --- /dev/null +++ b/apps/api/src/browserbase/browserbase-screenshot.service.ts @@ -0,0 +1,100 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { BUCKET_NAME, getSignedUrl, s3Client } from '@/app/s3'; +import { db } from '@db'; + +@Injectable() +export class BrowserbaseScreenshotService { + private get s3Client(): S3Client { + if (!s3Client) { + throw new Error( + 'S3 client not configured — set APP_AWS_ACCESS_KEY_ID, APP_AWS_SECRET_ACCESS_KEY, APP_AWS_REGION, APP_AWS_BUCKET_NAME in apps/api/.env', + ); + } + return s3Client; + } + + private get bucketName(): string { + if (!BUCKET_NAME) { + throw new Error( + 'APP_AWS_BUCKET_NAME is not set — configure S3 credentials in apps/api/.env', + ); + } + return BUCKET_NAME; + } + + async uploadScreenshot({ + organizationId, + automationId, + runId, + base64Screenshot, + }: { + organizationId: string; + automationId: string; + runId: string; + base64Screenshot: string; + }): Promise { + const buffer = Buffer.from(base64Screenshot, 'base64'); + const key = `browser-automations/${organizationId}/${automationId}/${runId}.jpg`; + + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.bucketName, + Key: key, + Body: buffer, + ContentType: 'image/jpeg', + }), + ); + + return key; + } + + async getPresignedUrl({ + key, + expiresIn, + responseContentDisposition, + }: { + key: string; + expiresIn?: number; + responseContentDisposition?: string; + }): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucketName, + Key: key, + ResponseContentDisposition: responseContentDisposition, + }); + return getSignedUrl(this.s3Client, command, { + expiresIn: expiresIn ?? 3600, + }); + } + + async getScreenshotRedirectUrl(input: { + runId: string; + organizationId: string; + download?: boolean; + }): Promise { + const { runId, organizationId, download } = input; + + const run = await db.browserAutomationRun.findUnique({ + where: { id: runId }, + include: { automation: { include: { task: true } } }, + }); + + if (!run || !run.screenshotUrl) { + throw new NotFoundException('Screenshot not found'); + } + + if (run.automation.task.organizationId !== organizationId) { + throw new NotFoundException('Screenshot not found'); + } + + const responseContentDisposition = download + ? `attachment; filename="screenshot-${runId}.jpg"` + : undefined; + + return this.getPresignedUrl({ + key: run.screenshotUrl, + responseContentDisposition, + }); + } +} diff --git a/apps/api/src/browserbase/browserbase-session.service.spec.ts b/apps/api/src/browserbase/browserbase-session.service.spec.ts new file mode 100644 index 0000000000..5a1c986dbe --- /dev/null +++ b/apps/api/src/browserbase/browserbase-session.service.spec.ts @@ -0,0 +1,254 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import Browserbase from '@browserbasehq/sdk'; +import { BrowserbaseSessionService } from './browserbase-session.service'; +import { browserbaseUnavailableException } from './browserbase-upstream-error'; + +jest.mock('@browserbasehq/sdk', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({})), +})); + +// Stagehand is loaded via a dynamic ESM import that jest cannot intercept, so +// tests spy on the loadStagehand() seam and supply a fake constructor instead. +type StagehandClass = Awaited< + ReturnType +>; + +const mockStagehandClass = ({ + init, + close, +}: { + init: jest.Mock; + close: jest.Mock; +}): StagehandClass => + jest.fn().mockImplementation(() => ({ init, close })) as unknown as StagehandClass; + +type BrowserbaseClient = ReturnType< + BrowserbaseSessionService['getBrowserbase'] +>; + +const prematureCloseError = () => + Object.assign(new Error('Invalid response body: Premature close'), { + code: 'ERR_STREAM_PREMATURE_CLOSE', + }); + +type MockBrowserbaseClientInput = { + createContext?: jest.Mock; + createSession?: jest.Mock; + debugSession?: jest.Mock; + retrieveSession?: jest.Mock; + updateSession?: jest.Mock; +}; + +const mockBrowserbaseClient = ({ + createContext = jest.fn(), + createSession = jest.fn(), + debugSession = jest.fn(), + retrieveSession = jest.fn(), + updateSession = jest.fn(), +}: MockBrowserbaseClientInput): BrowserbaseClient => + ({ + contexts: { + create: createContext, + }, + sessions: { + create: createSession, + debug: debugSession, + retrieve: retrieveSession, + update: updateSession, + }, + }) as unknown as BrowserbaseClient; + +describe('BrowserbaseSessionService', () => { + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('requests identity encoded Browserbase API responses', () => { + const service = new BrowserbaseSessionService(); + + service.getBrowserbase(); + + expect(jest.mocked(Browserbase)).toHaveBeenCalledWith( + expect.objectContaining({ + defaultHeaders: { 'accept-encoding': 'identity' }, + }), + ); + }); + + it('retries transient Browserbase context creation failures', async () => { + jest.useFakeTimers(); + const service = new BrowserbaseSessionService(); + const createContext = jest + .fn() + .mockRejectedValueOnce(prematureCloseError()) + .mockResolvedValueOnce({ id: 'ctx_1' }); + jest + .spyOn(service, 'getBrowserbase') + .mockReturnValue(mockBrowserbaseClient({ createContext })); + + const promise = service.createBrowserbaseContext(); + await jest.advanceTimersByTimeAsync(250); + + await expect(promise).resolves.toBe('ctx_1'); + expect(createContext).toHaveBeenCalledTimes(2); + }); + + it('returns a service unavailable exception after retry exhaustion', async () => { + jest.useFakeTimers(); + const service = new BrowserbaseSessionService(); + const createContext = jest.fn().mockRejectedValue(prematureCloseError()); + jest + .spyOn(service, 'getBrowserbase') + .mockReturnValue(mockBrowserbaseClient({ createContext })); + + const promise = service.createBrowserbaseContext(); + const expectation = expect(promise).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + await jest.advanceTimersByTimeAsync(1_000); + + await expectation; + expect(createContext).toHaveBeenCalledTimes(3); + }); + + it('preserves non-retryable Browserbase failures', async () => { + const service = new BrowserbaseSessionService(); + const browserbaseError = Object.assign( + new Error('Browserbase rejected request'), + { status: 400 }, + ); + const createContext = jest.fn().mockRejectedValue(browserbaseError); + jest + .spyOn(service, 'getBrowserbase') + .mockReturnValue(mockBrowserbaseClient({ createContext })); + + await expect(service.createBrowserbaseContext()).rejects.toBe( + browserbaseError, + ); + expect(createContext).toHaveBeenCalledTimes(1); + }); + + it('retries transient Browserbase session retrieval failures', async () => { + jest.useFakeTimers(); + const service = new BrowserbaseSessionService(); + const retrieveSession = jest + .fn() + .mockRejectedValueOnce(prematureCloseError()) + .mockResolvedValueOnce({ contextId: 'ctx_1' }); + jest + .spyOn(service, 'getBrowserbase') + .mockReturnValue(mockBrowserbaseClient({ retrieveSession })); + + const promise = service.getSessionContextId('session_1'); + await jest.advanceTimersByTimeAsync(250); + + await expect(promise).resolves.toBe('ctx_1'); + expect(retrieveSession).toHaveBeenCalledTimes(2); + expect(retrieveSession).toHaveBeenCalledWith('session_1'); + }); + + it('retries transient Browserbase live view lookup failures', async () => { + jest.useFakeTimers(); + const service = new BrowserbaseSessionService(); + const createSession = jest.fn().mockResolvedValue({ id: 'session_1' }); + const debugSession = jest + .fn() + .mockRejectedValueOnce(prematureCloseError()) + .mockResolvedValueOnce({ + debuggerFullscreenUrl: 'https://live.browserbase.test/session_1', + }); + jest.spyOn(service, 'getBrowserbase').mockReturnValue( + mockBrowserbaseClient({ + createSession, + debugSession, + }), + ); + + const promise = service.createSessionWithContext('ctx_1'); + await jest.advanceTimersByTimeAsync(250); + + await expect(promise).resolves.toEqual({ + sessionId: 'session_1', + liveViewUrl: 'https://live.browserbase.test/session_1', + }); + expect(createSession).toHaveBeenCalledTimes(1); + expect(debugSession).toHaveBeenCalledTimes(2); + }); + + it('retries transient Stagehand init failures', async () => { + jest.useFakeTimers(); + const service = new BrowserbaseSessionService(); + const init = jest + .fn() + .mockRejectedValueOnce(prematureCloseError()) + .mockResolvedValueOnce(undefined); + const close = jest.fn().mockResolvedValue(undefined); + const StagehandCtor = mockStagehandClass({ init, close }); + jest.spyOn(service, 'loadStagehand').mockResolvedValue(StagehandCtor); + + const promise = service.createStagehand('session_1'); + await jest.advanceTimersByTimeAsync(250); + + await expect(promise).resolves.toEqual({ init, close }); + expect(StagehandCtor).toHaveBeenCalledTimes(2); + expect(init).toHaveBeenCalledTimes(2); + // The partially-initialized instance is closed before retrying. + expect(close).toHaveBeenCalledTimes(1); + }); + + it('throws a service unavailable exception after Stagehand init retry exhaustion', async () => { + jest.useFakeTimers(); + const service = new BrowserbaseSessionService(); + const init = jest.fn().mockRejectedValue(prematureCloseError()); + const close = jest.fn().mockResolvedValue(undefined); + jest + .spyOn(service, 'loadStagehand') + .mockResolvedValue(mockStagehandClass({ init, close })); + + const promise = service.createStagehand('session_1'); + const expectation = expect(promise).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + await jest.advanceTimersByTimeAsync(1_000); + + await expectation; + expect(init).toHaveBeenCalledTimes(3); + expect(close).toHaveBeenCalledTimes(3); + }); + + it('preserves non-retryable Stagehand init failures', async () => { + const service = new BrowserbaseSessionService(); + const sessionNotFound = Object.assign(new Error('Session not found'), { + status: 404, + }); + const init = jest.fn().mockRejectedValue(sessionNotFound); + const close = jest.fn().mockResolvedValue(undefined); + jest + .spyOn(service, 'loadStagehand') + .mockResolvedValue(mockStagehandClass({ init, close })); + + await expect(service.createStagehand('session_1')).rejects.toBe( + sessionNotFound, + ); + expect(init).toHaveBeenCalledTimes(1); + // Even a non-retryable failure closes the partial instance. + expect(close).toHaveBeenCalledTimes(1); + }); + + it('navigateToUrl returns the friendly unavailable message when init keeps failing', async () => { + const service = new BrowserbaseSessionService(); + jest + .spyOn(service, 'createStagehand') + .mockRejectedValue(browserbaseUnavailableException()); + + await expect( + service.navigateToUrl('session_1', 'https://github.com'), + ).resolves.toEqual({ + success: false, + error: 'Browserbase is temporarily unavailable. Please retry in a moment.', + }); + }); +}); diff --git a/apps/api/src/browserbase/browserbase-session.service.ts b/apps/api/src/browserbase/browserbase-session.service.ts new file mode 100644 index 0000000000..df858f8efd --- /dev/null +++ b/apps/api/src/browserbase/browserbase-session.service.ts @@ -0,0 +1,313 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Browserbase from '@browserbasehq/sdk'; +import { z } from 'zod'; +import { + browserbaseUnavailableException, + getBrowserbaseErrorText, + isRetryableBrowserbaseUpstreamError, +} from './browserbase-upstream-error'; +import { isNoPageError } from './run-error-formatter'; + +type Stagehand = import('@browserbasehq/stagehand').Stagehand; + +const BROWSER_WIDTH = 1440; +const BROWSER_HEIGHT = 900; +const STAGEHAND_MODEL = 'anthropic/claude-sonnet-4-6'; +const BROWSERBASE_API_MAX_ATTEMPTS = 3; +const BROWSERBASE_RETRY_DELAYS_MS = [250, 750]; +const BROWSERBASE_DEFAULT_HEADERS = { 'accept-encoding': 'identity' }; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +@Injectable() +export class BrowserbaseSessionService { + private readonly logger = new Logger(BrowserbaseSessionService.name); + + getBrowserbase() { + return new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY, + defaultHeaders: BROWSERBASE_DEFAULT_HEADERS, + }); + } + + getProjectId() { + return process.env.BROWSERBASE_PROJECT_ID || ''; + } + + async createBrowserbaseContext(): Promise { + return this.withBrowserbaseRetry({ + operationName: 'context creation', + operation: async () => { + const context = await this.getBrowserbase().contexts.create({ + projectId: this.getProjectId(), + }); + return context.id; + }, + }); + } + + async createSessionWithContext( + contextId: string, + ): Promise<{ sessionId: string; liveViewUrl: string }> { + const bb = this.getBrowserbase(); + + const session = await this.withBrowserbaseRetry({ + operationName: 'session creation', + operation: () => + bb.sessions.create({ + projectId: this.getProjectId(), + browserSettings: { + context: { + id: contextId, + persist: true, + }, + fingerprint: { + screen: { + maxHeight: BROWSER_HEIGHT, + maxWidth: BROWSER_WIDTH, + minHeight: BROWSER_HEIGHT, + minWidth: BROWSER_WIDTH, + }, + }, + viewport: { width: BROWSER_WIDTH, height: BROWSER_HEIGHT }, + }, + keepAlive: true, + }), + }); + + try { + const debug = await this.withBrowserbaseRetry({ + operationName: 'session debug URL lookup', + operation: () => bb.sessions.debug(session.id), + }); + + return { + sessionId: session.id, + liveViewUrl: debug.debuggerFullscreenUrl, + }; + } catch (error) { + try { + await this.closeSession(session.id); + } catch { + // Ignore best-effort cleanup errors after a failed live-view lookup. + } + throw error; + } + } + + async closeSession(sessionId: string): Promise { + await this.withBrowserbaseRetry({ + operationName: 'session close', + operation: () => + this.getBrowserbase().sessions.update(sessionId, { + projectId: this.getProjectId(), + status: 'REQUEST_RELEASE', + }), + }); + } + + async getSessionContextId(sessionId: string): Promise { + const session = await this.withBrowserbaseRetry({ + operationName: 'session retrieval', + operation: () => this.getBrowserbase().sessions.retrieve(sessionId), + }); + return session.contextId; + } + + private async withBrowserbaseRetry({ + operation, + operationName, + }: { + operation: () => Promise; + operationName: string; + }): Promise { + for ( + let attempt = 1; + attempt <= BROWSERBASE_API_MAX_ATTEMPTS; + attempt += 1 + ) { + try { + return await operation(); + } catch (error) { + const retryable = isRetryableBrowserbaseUpstreamError(error); + if (!retryable) { + this.logger.error(`Browserbase ${operationName} failed`, { + attempt, + error: getBrowserbaseErrorText(error), + }); + throw error; + } + + if (attempt === BROWSERBASE_API_MAX_ATTEMPTS) { + this.logger.error(`Browserbase ${operationName} failed`, { + attempt, + error: getBrowserbaseErrorText(error), + }); + throw browserbaseUnavailableException(); + } + + this.logger.warn(`Browserbase ${operationName} failed; retrying`, { + attempt, + error: getBrowserbaseErrorText(error), + }); + await delay(BROWSERBASE_RETRY_DELAYS_MS[attempt - 1] ?? 1000); + } + } + + throw browserbaseUnavailableException(); + } + + async loadStagehand(): Promise< + typeof import('@browserbasehq/stagehand').Stagehand + > { + const { Stagehand } = await import('@browserbasehq/stagehand'); + return Stagehand; + } + + async createStagehand(sessionId: string): Promise { + const Stagehand = await this.loadStagehand(); + + // Stagehand.init() resumes the session by calling the Browserbase API on + // its own internal SDK client (outside getBrowserbase()), so transient + // upstream failures there — e.g. "Premature close" — bypass the per-call + // retry that wraps our direct SDK calls. Retry init the same way, closing + // any half-initialized instance between attempts so we don't leak it. + return this.withBrowserbaseRetry({ + operationName: 'stagehand initialization', + operation: async () => { + const stagehand = new Stagehand({ + env: 'BROWSERBASE', + apiKey: process.env.BROWSERBASE_API_KEY, + projectId: this.getProjectId(), + browserbaseSessionID: sessionId, + model: { + modelName: STAGEHAND_MODEL, + apiKey: process.env.ANTHROPIC_API_KEY, + }, + verbose: 1, + }); + + try { + await stagehand.init(); + return stagehand; + } catch (error) { + // keepAlive:true means close() will not end the Browserbase session, + // so the next attempt can resume the same sessionId. + await this.safeCloseStagehand(stagehand); + throw error; + } + }, + }); + } + + async safeCloseStagehand(stagehand: Stagehand): Promise { + try { + await stagehand.close(); + } catch (err) { + this.logger.warn('Failed to close stagehand (ignored)', { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + async ensureActivePage(stagehand: Stagehand) { + const maxWaitMs = 5_000; + const pollMs = 250; + const startedAt = Date.now(); + + while (Date.now() - startedAt < maxWaitMs) { + const page = stagehand.context.pages().find((candidate) => { + const maybePage = candidate as { isClosed?: () => boolean }; + return maybePage.isClosed ? !maybePage.isClosed() : true; + }); + if (page) return page; + await delay(pollMs); + } + + return await stagehand.context.newPage(); + } + + async navigateToUrl( + sessionId: string, + url: string, + ): Promise<{ success: boolean; error?: string }> { + let stagehand: Stagehand | null = null; + + try { + stagehand = await this.createStagehand(sessionId); + const page = await this.ensureActivePage(stagehand); + + await page.sendCDP('WebAuthn.enable'); + await page.sendCDP('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + + await page.goto(url, { + waitUntil: 'domcontentloaded', + timeoutMs: 30000, + }); + + return { success: true }; + } catch (err) { + this.logger.error('Failed to navigate to URL', err); + return { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } finally { + if (stagehand) { + await this.safeCloseStagehand(stagehand); + } + } + } + + async checkLoginStatus( + sessionId: string, + url: string, + ): Promise<{ isLoggedIn: boolean; username?: string }> { + const stagehand = await this.createStagehand(sessionId); + + try { + const page = await this.ensureActivePage(stagehand); + + await page.goto(url, { + waitUntil: 'domcontentloaded', + timeoutMs: 30000, + }); + await delay(1500); + + const loginSchema = z.object({ + isLoggedIn: z + .boolean() + .describe('Whether the user is currently logged in to this site'), + username: z.string().optional().describe('The username if logged in'), + }); + + const result = await stagehand.extract( + 'Check if the user is logged in to this website. Look for a user avatar, profile menu, or account dropdown in the header/navigation. If logged in, extract the username if visible.', + loginSchema, + ); + + return { + isLoggedIn: result.isLoggedIn, + username: result.username, + }; + } catch (err) { + if (isNoPageError(err)) { + throw new Error( + 'Browser session ended before we could verify login status. Please retry.', + ); + } + throw err; + } finally { + await this.safeCloseStagehand(stagehand); + } + } +} diff --git a/apps/api/src/browserbase/browserbase-upstream-error.spec.ts b/apps/api/src/browserbase/browserbase-upstream-error.spec.ts new file mode 100644 index 0000000000..0cb3eaa288 --- /dev/null +++ b/apps/api/src/browserbase/browserbase-upstream-error.spec.ts @@ -0,0 +1,29 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { + browserbaseUnavailableException, + isRetryableBrowserbaseUpstreamError, +} from './browserbase-upstream-error'; + +describe('browserbase upstream errors', () => { + it('treats premature close as retryable', () => { + const error = Object.assign(new Error('Premature close'), { + code: 'ERR_STREAM_PREMATURE_CLOSE', + errno: 'ERR_STREAM_PREMATURE_CLOSE', + }); + + expect(isRetryableBrowserbaseUpstreamError(error)).toBe(true); + }); + + it('treats upstream 5xx responses as retryable', () => { + expect(isRetryableBrowserbaseUpstreamError({ status: 502 })).toBe(true); + }); + + it('uses a stable request-facing unavailable exception', () => { + const error = browserbaseUnavailableException(); + + expect(error).toBeInstanceOf(ServiceUnavailableException); + expect(error.message).toBe( + 'Browserbase is temporarily unavailable. Please retry in a moment.', + ); + }); +}); diff --git a/apps/api/src/browserbase/browserbase-upstream-error.ts b/apps/api/src/browserbase/browserbase-upstream-error.ts new file mode 100644 index 0000000000..ed054baa8c --- /dev/null +++ b/apps/api/src/browserbase/browserbase-upstream-error.ts @@ -0,0 +1,71 @@ +import { ServiceUnavailableException } from '@nestjs/common'; + +const RETRYABLE_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ERR_STREAM_PREMATURE_CLOSE', +]); + +const RETRYABLE_MESSAGE_PARTS = [ + 'premature close', + 'socket hang up', + 'fetch failed', + 'network timeout', + 'timeout', + 'temporarily unavailable', +]; + +const readStringProperty = ({ + value, + property, +}: { + value: unknown; + property: string; +}): string | undefined => { + if (typeof value !== 'object' || value === null) return undefined; + if (!(property in value)) return undefined; + const propertyValue = (value as Record)[property]; + return typeof propertyValue === 'string' ? propertyValue : undefined; +}; + +const readNumberProperty = ({ + value, + property, +}: { + value: unknown; + property: string; +}): number | undefined => { + if (typeof value !== 'object' || value === null) return undefined; + if (!(property in value)) return undefined; + const propertyValue = (value as Record)[property]; + return typeof propertyValue === 'number' ? propertyValue : undefined; +}; + +export const getBrowserbaseErrorText = (error: unknown): string => { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + return String(error); +}; + +export const isRetryableBrowserbaseUpstreamError = ( + error: unknown, +): boolean => { + const code = + readStringProperty({ value: error, property: 'code' }) ?? + readStringProperty({ value: error, property: 'errno' }); + if (code && RETRYABLE_ERROR_CODES.has(code)) return true; + + const status = + readNumberProperty({ value: error, property: 'status' }) ?? + readNumberProperty({ value: error, property: 'statusCode' }); + if (status && (status === 429 || status >= 500)) return true; + + const message = getBrowserbaseErrorText(error).toLowerCase(); + return RETRYABLE_MESSAGE_PARTS.some((part) => message.includes(part)); +}; + +export const browserbaseUnavailableException = () => + new ServiceUnavailableException( + 'Browserbase is temporarily unavailable. Please retry in a moment.', + ); diff --git a/apps/api/src/browserbase/browserbase-url.ts b/apps/api/src/browserbase/browserbase-url.ts new file mode 100644 index 0000000000..9bf2def94f --- /dev/null +++ b/apps/api/src/browserbase/browserbase-url.ts @@ -0,0 +1,12 @@ +export function normalizeHostnameFromUrl(url: string): string { + const parsed = new URL(url); + return parsed.hostname.toLowerCase().replace(/\.$/, ''); +} + +export function defaultProfileDisplayName(hostname: string): string { + return `${hostname} browser profile`; +} + +export function normalizeLoginIdentity(loginIdentity?: string | null): string { + return loginIdentity?.trim().toLowerCase() ?? ''; +} diff --git a/apps/api/src/browserbase/browserbase.controller.ts b/apps/api/src/browserbase/browserbase.controller.ts index a0c7047fee..a02d1fb69c 100644 --- a/apps/api/src/browserbase/browserbase.controller.ts +++ b/apps/api/src/browserbase/browserbase.controller.ts @@ -13,6 +13,7 @@ import { import type { Response } from 'express'; import { ApiOperation, + ApiBody, ApiParam, ApiResponse, ApiSecurity, @@ -32,6 +33,7 @@ import { ContextResponseDto, CreateBrowserAutomationDto, CreateSessionDto, + ExecuteAutomationSessionDto, NavigateToUrlDto, RunAutomationResponseDto, SessionResponseDto, @@ -171,10 +173,12 @@ export class BrowserbaseController { type: BrowserAutomationResponseDto, }) async createAutomation( + @OrganizationId() organizationId: string, @Body() dto: CreateBrowserAutomationDto, ): Promise { return (await this.browserbaseService.createBrowserAutomation( dto, + organizationId, )) as BrowserAutomationResponseDto; } @@ -191,9 +195,11 @@ export class BrowserbaseController { }) async getAutomationsForTask( @Param('taskId') taskId: string, + @OrganizationId() organizationId: string, ): Promise { return (await this.browserbaseService.getAutomationsWithPresignedUrls( taskId, + organizationId, )) as BrowserAutomationResponseDto[]; } @@ -210,9 +216,11 @@ export class BrowserbaseController { }) async getAutomation( @Param('automationId') automationId: string, + @OrganizationId() organizationId: string, ): Promise { return (await this.browserbaseService.getBrowserAutomation( automationId, + organizationId, )) as BrowserAutomationResponseDto | null; } @@ -229,11 +237,13 @@ export class BrowserbaseController { }) async updateAutomation( @Param('automationId') automationId: string, + @OrganizationId() organizationId: string, @Body() dto: UpdateBrowserAutomationDto, ): Promise { return (await this.browserbaseService.updateBrowserAutomation( automationId, dto, + organizationId, )) as BrowserAutomationResponseDto; } @@ -249,8 +259,12 @@ export class BrowserbaseController { }) async deleteAutomation( @Param('automationId') automationId: string, + @OrganizationId() organizationId: string, ): Promise<{ success: boolean }> { - await this.browserbaseService.deleteBrowserAutomation(automationId); + await this.browserbaseService.deleteBrowserAutomation( + automationId, + organizationId, + ); return { success: true }; } @@ -291,13 +305,14 @@ export class BrowserbaseController { description: 'Runs the automation on a pre-created session', }) @ApiParam({ name: 'automationId', description: 'Automation ID' }) + @ApiBody({ type: ExecuteAutomationSessionDto }) @ApiResponse({ status: 200, description: 'Execution result', }) async executeAutomationOnSession( @Param('automationId') automationId: string, - @Body() body: { runId: string; sessionId: string }, + @Body() body: ExecuteAutomationSessionDto, @OrganizationId() organizationId: string, ): Promise<{ success: boolean; @@ -350,9 +365,12 @@ export class BrowserbaseController { }) async getAutomationRuns( @Param('automationId') automationId: string, + @OrganizationId() organizationId: string, ): Promise { return (await this.browserbaseService.getAutomationRuns( automationId, + 20, + organizationId, )) as BrowserAutomationRunResponseDto[]; } @@ -369,9 +387,11 @@ export class BrowserbaseController { }) async getRunById( @Param('runId') runId: string, + @OrganizationId() organizationId: string, ): Promise { return (await this.browserbaseService.getRunWithPresignedUrl( runId, + organizationId, )) as BrowserAutomationRunResponseDto | null; } diff --git a/apps/api/src/browserbase/browserbase.module.ts b/apps/api/src/browserbase/browserbase.module.ts index 9dfec69375..f0497ab123 100644 --- a/apps/api/src/browserbase/browserbase.module.ts +++ b/apps/api/src/browserbase/browserbase.module.ts @@ -1,12 +1,33 @@ import { Module } from '@nestjs/common'; +import { BrowserAutomationCrudService } from './browser-automation-crud.service'; +import { BrowserAutomationExecutionService } from './browser-automation-execution.service'; +import { BrowserAutomationRunStoreService } from './browser-automation-run-store.service'; +import { BrowserAuthProfilesController } from './browser-auth-profiles.controller'; +import { BrowserAuthProfileContextService } from './browser-auth-profile-context.service'; +import { BrowserAuthProfileService } from './browser-auth-profile.service'; +import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; import { BrowserbaseController } from './browserbase.controller'; +import { BrowserbaseOrgContextService } from './browserbase-org-context.service'; +import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; +import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserbaseService } from './browserbase.service'; import { AuthModule } from '../auth/auth.module'; @Module({ imports: [AuthModule], - controllers: [BrowserbaseController], - providers: [BrowserbaseService], + controllers: [BrowserbaseController, BrowserAuthProfilesController], + providers: [ + BrowserbaseService, + BrowserbaseSessionService, + BrowserAutomationCrudService, + BrowserAutomationExecutionService, + BrowserAutomationRunStoreService, + BrowserAuthProfileContextService, + BrowserAuthProfileService, + BrowserbaseOrgContextService, + BrowserbaseScreenshotService, + BrowserEvidenceRunnerService, + ], exports: [BrowserbaseService], }) export class BrowserbaseModule {} diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts index 7f8cdba32f..29d21ee2f0 100644 --- a/apps/api/src/browserbase/browserbase.service.spec.ts +++ b/apps/api/src/browserbase/browserbase.service.spec.ts @@ -1,6 +1,15 @@ // apps/api/src/browserbase/browserbase.service.spec.ts import { Test } from '@nestjs/testing'; import { NotFoundException } from '@nestjs/common'; +import { BrowserAutomationCrudService } from './browser-automation-crud.service'; +import { BrowserAutomationExecutionService } from './browser-automation-execution.service'; +import { BrowserAutomationRunStoreService } from './browser-automation-run-store.service'; +import { BrowserAuthProfileContextService } from './browser-auth-profile-context.service'; +import { BrowserAuthProfileService } from './browser-auth-profile.service'; +import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; +import { BrowserbaseOrgContextService } from './browserbase-org-context.service'; +import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; +import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserbaseService } from './browserbase.service'; jest.mock('@db', () => ({ @@ -37,7 +46,18 @@ describe('BrowserbaseService.getScreenshotRedirectUrl', () => { beforeEach(async () => { jest.clearAllMocks(); const moduleRef = await Test.createTestingModule({ - providers: [BrowserbaseService], + providers: [ + BrowserbaseService, + BrowserbaseSessionService, + BrowserAutomationCrudService, + BrowserAutomationExecutionService, + BrowserAutomationRunStoreService, + BrowserAuthProfileContextService, + BrowserAuthProfileService, + BrowserbaseOrgContextService, + BrowserbaseScreenshotService, + BrowserEvidenceRunnerService, + ], }).compile(); service = moduleRef.get(BrowserbaseService); }); @@ -144,7 +164,18 @@ describe('BrowserbaseService schedule frequency passthrough', () => { beforeEach(async () => { jest.clearAllMocks(); const moduleRef = await Test.createTestingModule({ - providers: [BrowserbaseService], + providers: [ + BrowserbaseService, + BrowserbaseSessionService, + BrowserAutomationCrudService, + BrowserAutomationExecutionService, + BrowserAutomationRunStoreService, + BrowserAuthProfileContextService, + BrowserAuthProfileService, + BrowserbaseOrgContextService, + BrowserbaseScreenshotService, + BrowserEvidenceRunnerService, + ], }).compile(); service = moduleRef.get(BrowserbaseService); }); diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index da81adadea..696133553f 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -1,403 +1,117 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import Browserbase from '@browserbasehq/sdk'; -// Lazy-imported in createStagehand() to avoid Node v25 crash -// (SlowBuffer.prototype was removed — @browserbasehq/stagehand bundles buffer-equal-constant-time which uses it) -type Stagehand = import('@browserbasehq/stagehand').Stagehand; -import { db, TaskFrequency } from '@db'; -import { z } from 'zod'; -import { - GetObjectCommand, - PutObjectCommand, - S3Client, -} from '@aws-sdk/client-s3'; -import { BUCKET_NAME, getSignedUrl, s3Client } from '@/app/s3'; -import { renderOverlay } from './screenshot-overlay'; -import { isNoPageError, toRunErrorMessage } from './run-error-formatter'; - -const BROWSER_WIDTH = 1440; -const BROWSER_HEIGHT = 900; - -/** Stagehand v3 requires 'provider/model' format. */ -const STAGEHAND_MODEL = 'anthropic/claude-sonnet-4-6'; -const STAGEHAND_CUA_MODEL = 'anthropic/claude-sonnet-4-6'; - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -const PENDING_CONTEXT_ID = '__PENDING__'; - -/** Empty strings become null; any actual text is trimmed and kept. */ -const normalizeCriteria = (value: string | null | undefined): string | null => { - if (value == null) return null; - const trimmed = value.trim(); - return trimmed.length === 0 ? null : trimmed; -}; - -const isPrismaUniqueConstraintError = (error: unknown): boolean => { - if (typeof error !== 'object' || error === null) return false; - if (!('code' in error)) return false; - const code = (error as { code?: unknown }).code; - return code === 'P2002'; -}; +import { Injectable } from '@nestjs/common'; +import { TaskFrequency } from '@db'; +import { BrowserAutomationCrudService } from './browser-automation-crud.service'; +import { BrowserAutomationExecutionService } from './browser-automation-execution.service'; +import { BrowserAuthProfileService } from './browser-auth-profile.service'; +import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; +import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; +import { BrowserbaseSessionService } from './browserbase-session.service'; +import { normalizeHostnameFromUrl } from './browserbase-url'; @Injectable() export class BrowserbaseService { - private readonly logger = new Logger(BrowserbaseService.name); - - private get s3Client(): S3Client { - if (!s3Client) { - throw new Error( - 'S3 client not configured — set APP_AWS_ACCESS_KEY_ID, APP_AWS_SECRET_ACCESS_KEY, APP_AWS_REGION, APP_AWS_BUCKET_NAME in apps/api/.env', - ); - } - return s3Client; + constructor( + private readonly sessions: BrowserbaseSessionService = new BrowserbaseSessionService(), + private readonly profiles: BrowserAuthProfileService = new BrowserAuthProfileService( + sessions, + ), + private readonly screenshots: BrowserbaseScreenshotService = new BrowserbaseScreenshotService(), + private readonly runner: BrowserEvidenceRunnerService = new BrowserEvidenceRunnerService( + sessions, + screenshots, + ), + private readonly automationCrud: BrowserAutomationCrudService = new BrowserAutomationCrudService( + screenshots, + ), + private readonly automationExecution: BrowserAutomationExecutionService = + new BrowserAutomationExecutionService(sessions, profiles, runner), + ) {} + + async listAuthProfiles(organizationId: string) { + return this.profiles.listProfiles(organizationId); + } + + async getOrCreateAuthProfile(input: { + organizationId: string; + url: string; + displayName?: string; + loginIdentity?: string; + vaultProvider?: string; + vaultExternalItemRef?: string; + vaultConnectionId?: string; + }) { + return this.profiles.getOrCreateProfileFromUrl(input); } - private get bucketName(): string { - if (!BUCKET_NAME) { - throw new Error( - 'APP_AWS_BUCKET_NAME is not set — configure S3 credentials in apps/api/.env', - ); - } - return BUCKET_NAME; + async startAuthProfileSession(input: { + organizationId: string; + profileId: string; + }) { + return this.profiles.startProfileSession(input); } - private getBrowserbase() { - return new Browserbase({ - apiKey: process.env.BROWSERBASE_API_KEY, - }); + async verifyAuthProfileSession(input: { + organizationId: string; + profileId: string; + sessionId: string; + url: string; + }) { + return this.profiles.verifyProfileSession(input); } - private getProjectId() { - return process.env.BROWSERBASE_PROJECT_ID || ''; + async markAuthProfileNeedsReauth(input: { + organizationId: string; + profileId: string; + reason?: string; + }) { + return this.profiles.markNeedsReauth(input); } - /** - * Stagehand sometimes has no active page (or the page gets closed mid-run), - * which causes errors like: "No Page found for awaitActivePage: no page available". - * Ensure there's at least one non-closed page available, and create one if needed. - */ - private async ensureActivePage(stagehand: Stagehand) { - const MAX_WAIT_MS = 5000; - const POLL_MS = 250; - const startedAt = Date.now(); - - while (Date.now() - startedAt < MAX_WAIT_MS) { - // Stagehand's Page type doesn't always expose Playwright's `isClosed()` in typings. - // We still want to filter out closed pages at runtime when possible. - const pages = stagehand.context.pages().filter((p) => { - const maybeIsClosed = (p as { isClosed?: () => boolean }).isClosed; - return typeof maybeIsClosed === 'function' ? !maybeIsClosed() : true; - }); - if (pages[0]) return pages[0]; - await delay(POLL_MS); - } - - // Last resort: create a page (may still fail if the CDP session already died) - return await stagehand.context.newPage(); + async getOrCreateOrgContext(organizationId: string) { + return this.profiles.getOrCreateOrgContext(organizationId); } - // ===== Organization Context Management ===== - - async getOrCreateOrgContext( - organizationId: string, - ): Promise<{ contextId: string; isNew: boolean }> { - // Fast path: already created - const existing = await db.browserbaseContext.findUnique({ - where: { organizationId }, - }); - - if (existing && existing.contextId !== PENDING_CONTEXT_ID) { - return { contextId: existing.contextId, isNew: false }; - } - - try { - await db.browserbaseContext.create({ - data: { - organizationId, - contextId: PENDING_CONTEXT_ID, - }, - }); - - const bb = this.getBrowserbase(); - const context = await bb.contexts.create({ - projectId: this.getProjectId(), - }); - - await db.browserbaseContext.update({ - where: { organizationId }, - data: { contextId: context.id }, - }); - - return { contextId: context.id, isNew: true }; - } catch (error) { - if (!isPrismaUniqueConstraintError(error)) { - throw error; - } - } - - const MAX_WAIT_MS = 10_000; - const POLL_MS = 200; - const startedAt = Date.now(); - - while (Date.now() - startedAt < MAX_WAIT_MS) { - const current = await db.browserbaseContext.findUnique({ - where: { organizationId }, - }); - - if (current && current.contextId !== PENDING_CONTEXT_ID) { - return { contextId: current.contextId, isNew: false }; - } - - if (!current) { - return await this.getOrCreateOrgContext(organizationId); - } - - await delay(POLL_MS); - } - - this.logger.warn( - `Timed out waiting for Browserbase context creation for org ${organizationId}`, - ); - throw new Error( - 'Browser context initialization is taking too long. Please retry.', - ); + async getOrgContext(organizationId: string) { + return this.profiles.getOrgContext(organizationId); } - async getOrgContext( - organizationId: string, - ): Promise<{ contextId: string } | null> { - const context = await db.browserbaseContext.findUnique({ - where: { organizationId }, - }); - - if (!context) return null; - return { contextId: context.contextId }; - } - - // ===== Session Management ===== - - async createSessionWithContext( - contextId: string, - ): Promise<{ sessionId: string; liveViewUrl: string }> { - const bb = this.getBrowserbase(); - - const session = await bb.sessions.create({ - projectId: this.getProjectId(), - browserSettings: { - context: { - id: contextId, - persist: true, - }, - fingerprint: { - screen: { - maxHeight: BROWSER_HEIGHT, - maxWidth: BROWSER_WIDTH, - minHeight: BROWSER_HEIGHT, - minWidth: BROWSER_WIDTH, - }, - }, - viewport: { width: BROWSER_WIDTH, height: BROWSER_HEIGHT }, - }, - keepAlive: true, - }); - - const debug = await bb.sessions.debug(session.id); - - return { - sessionId: session.id, - liveViewUrl: debug.debuggerFullscreenUrl, - }; + async createSessionWithContext(contextId: string) { + return this.sessions.createSessionWithContext(contextId); } async closeSession(sessionId: string): Promise { - const bb = this.getBrowserbase(); - await bb.sessions.update(sessionId, { - projectId: this.getProjectId(), - status: 'REQUEST_RELEASE', - }); - } - - // ===== Stagehand helpers ===== - - private async createStagehand(sessionId: string): Promise { - const { Stagehand } = await import('@browserbasehq/stagehand'); - const stagehand = new Stagehand({ - env: 'BROWSERBASE', - apiKey: process.env.BROWSERBASE_API_KEY, - projectId: this.getProjectId(), - browserbaseSessionID: sessionId, - model: { - modelName: STAGEHAND_MODEL, - apiKey: process.env.ANTHROPIC_API_KEY, - }, - verbose: 1, - }); - - await stagehand.init(); - return stagehand; - } - - private async safeCloseStagehand(stagehand: Stagehand) { - try { - await stagehand.close(); - } catch (err) { - // IMPORTANT: never let cleanup errors override a successful run - this.logger.warn('Failed to close stagehand (ignored)', { - error: err instanceof Error ? err.message : String(err), - }); - } + return this.sessions.closeSession(sessionId); } - // ===== Browser Actions ===== - - async navigateToUrl( - sessionId: string, - url: string, - ): Promise<{ success: boolean; error?: string }> { - let stagehand: Stagehand | null = null; - - try { - stagehand = await this.createStagehand(sessionId); - const page = stagehand.context.pages()[0]; - if (!page) { - throw new Error('No page found in browser session'); - } - - // Set up virtual authenticator to bypass passkeys via CDP - await page.sendCDP('WebAuthn.enable'); - await page.sendCDP('WebAuthn.addVirtualAuthenticator', { - options: { - protocol: 'ctap2', - transport: 'internal', - hasResidentKey: true, - hasUserVerification: true, - isUserVerified: true, - automaticPresenceSimulation: true, - }, - }); - - await page.goto(url, { - waitUntil: 'domcontentloaded', - timeoutMs: 30000, - }); - - return { success: true }; - } catch (err) { - this.logger.error('Failed to navigate to URL', err); - if (stagehand) { - try { - await stagehand.close(); - } catch (closeErr) { - this.logger.warn('Failed to close stagehand after navigation error', { - closeErr: - closeErr instanceof Error - ? closeErr.message - : 'Unknown close error', - }); - } - } - return { - success: false, - error: err instanceof Error ? err.message : 'Unknown error', - }; - } - // Don't close - user needs to interact via Live View + async navigateToUrl(sessionId: string, url: string) { + return this.sessions.navigateToUrl(sessionId, url); } - async checkLoginStatus( - sessionId: string, - url: string, - ): Promise<{ isLoggedIn: boolean; username?: string }> { - const stagehand = await this.createStagehand(sessionId); - - try { - const page = await this.ensureActivePage(stagehand); - - await page.goto(url, { - waitUntil: 'domcontentloaded', - timeoutMs: 30000, - }); - await delay(1500); - - // Use extract to check login status - const loginSchema = z.object({ - isLoggedIn: z - .boolean() - .describe('Whether the user is currently logged in to this site'), - username: z.string().optional().describe('The username if logged in'), - }); - - const result = (await stagehand.extract( - 'Check if the user is logged in to this website. Look for a user avatar, profile menu, or account dropdown in the header/navigation. If logged in, extract the username if visible.', - loginSchema as any, - )) as { isLoggedIn: boolean; username?: string }; - - return { - isLoggedIn: result.isLoggedIn, - username: result.username, - }; - } catch (err) { - if (isNoPageError(err)) { - throw new Error( - 'Browser session ended before we could verify login status. Please retry.', - ); - } - throw err; - } finally { - await this.safeCloseStagehand(stagehand); - } + async checkLoginStatus(sessionId: string, url: string) { + return this.sessions.checkLoginStatus(sessionId, url); } - // ===== Browser Automation CRUD ===== - - async createBrowserAutomation(data: { - taskId: string; - name: string; - description?: string; - targetUrl: string; - instruction: string; - evaluationCriteria?: string; - scheduleFrequency?: TaskFrequency; - }) { - return db.browserAutomation.create({ - data: { - taskId: data.taskId, - name: data.name, - description: data.description, - targetUrl: data.targetUrl, - instruction: data.instruction, - evaluationCriteria: normalizeCriteria(data.evaluationCriteria), - isEnabled: true, // Enable by default so scheduled runs work - ...(data.scheduleFrequency !== undefined - ? { scheduleFrequency: data.scheduleFrequency } - : {}), - }, - }); + async createBrowserAutomation( + data: { + taskId: string; + name: string; + description?: string; + targetUrl: string; + instruction: string; + evaluationCriteria?: string; + scheduleFrequency?: TaskFrequency; + }, + organizationId?: string, + ) { + return this.automationCrud.createBrowserAutomation(data, organizationId); } - async getBrowserAutomation(automationId: string) { - return db.browserAutomation.findUnique({ - where: { id: automationId }, - include: { - runs: { - orderBy: { createdAt: 'desc' }, - take: 10, - }, - }, - }); + async getBrowserAutomation(automationId: string, organizationId?: string) { + return this.automationCrud.getBrowserAutomation(automationId, organizationId); } - async getBrowserAutomationsForTask(taskId: string) { - return db.browserAutomation.findMany({ - where: { taskId }, - include: { - runs: { - orderBy: { createdAt: 'desc' }, - take: 1, - }, - }, - orderBy: { createdAt: 'desc' }, - }); + async getBrowserAutomationsForTask(taskId: string, organizationId?: string) { + return this.automationCrud.getBrowserAutomationsForTask(taskId, organizationId); } async updateBrowserAutomation( @@ -411,634 +125,83 @@ export class BrowserbaseService { isEnabled?: boolean; scheduleFrequency?: TaskFrequency; }, + organizationId?: string, ) { - const { evaluationCriteria, scheduleFrequency, ...rest } = data; - return db.browserAutomation.update({ - where: { id: automationId }, - data: { - ...rest, - ...(evaluationCriteria !== undefined - ? { evaluationCriteria: normalizeCriteria(evaluationCriteria) } - : {}), - ...(scheduleFrequency !== undefined ? { scheduleFrequency } : {}), - }, - }); + return this.automationCrud.updateBrowserAutomation( + automationId, + data, + organizationId, + ); } - async deleteBrowserAutomation(automationId: string) { - return db.browserAutomation.delete({ - where: { id: automationId }, - }); + async deleteBrowserAutomation(automationId: string, organizationId?: string) { + return this.automationCrud.deleteBrowserAutomation(automationId, organizationId); } - // ===== Browser Automation Execution ===== - - /** - * Start an automation run with a live session that the user can watch - */ - async startAutomationWithLiveView( - automationId: string, - organizationId: string, - ): Promise<{ - runId: string; - sessionId: string; - liveViewUrl: string; - error?: string; - needsReauth?: boolean; - }> { - const automation = await db.browserAutomation.findUnique({ - where: { id: automationId }, - }); - - if (!automation) { - throw new Error('Automation not found'); - } - - const context = await this.getOrgContext(organizationId); - if (!context) { - return { - runId: '', - sessionId: '', - liveViewUrl: '', - needsReauth: true, - error: 'No browser context found. Please connect your browser first.', - }; - } - - // Create a run record - const run = await db.browserAutomationRun.create({ - data: { - automationId, - status: 'running', - startedAt: new Date(), - }, - }); - - // Create session with live view - const { sessionId, liveViewUrl } = await this.createSessionWithContext( - context.contextId, + async startAutomationWithLiveView(automationId: string, organizationId: string) { + return this.automationExecution.startAutomationWithLiveView( + automationId, + organizationId, ); - - return { - runId: run.id, - sessionId, - liveViewUrl, - }; } - /** - * Execute an automation on an existing session (for live view runs) - */ async executeAutomationOnSession( automationId: string, runId: string, sessionId: string, organizationId: string, - ): Promise<{ - success: boolean; - screenshotUrl?: string; - evaluationStatus?: 'pass' | 'fail'; - evaluationReason?: string; - error?: string; - needsReauth?: boolean; - }> { - const automation = await db.browserAutomation.findUnique({ - where: { id: automationId }, - include: { - task: { - select: { title: true, description: true }, - }, - }, - }); - - if (!automation) { - throw new Error('Automation not found'); - } - - const run = await db.browserAutomationRun.findUnique({ - where: { id: runId }, - }); - - if (!run) { - throw new Error('Run not found'); - } - - try { - const result = await this.executeAutomation( - sessionId, - automation.targetUrl, - automation.instruction, - { - title: automation.task.title, - description: automation.task.description, - evaluationCriteria: automation.evaluationCriteria, - }, - ); - - if (!result.success) { - // Store evaluation data even on failure (requirement not met) - await db.browserAutomationRun.update({ - where: { id: runId }, - data: { - status: 'failed', - completedAt: new Date(), - durationMs: run.startedAt - ? Date.now() - run.startedAt.getTime() - : 0, - error: result.error, - evaluationStatus: result.evaluationStatus, - evaluationReason: result.evaluationReason, - }, - }); - - return { - success: false, - error: result.error, - evaluationStatus: result.evaluationStatus, - evaluationReason: result.evaluationReason, - needsReauth: result.needsReauth, - }; - } - - // Upload screenshot to S3 - let screenshotKey: string | undefined; - let presignedUrl: string | undefined; - if (result.screenshot) { - screenshotKey = await this.uploadScreenshot( - organizationId, - automationId, - runId, - result.screenshot, - ); - presignedUrl = await this.getPresignedUrl(screenshotKey); - } - - // Update run as completed. Only persist an evaluation verdict when - // the caller's automation had criteria configured. - await db.browserAutomationRun.update({ - where: { id: runId }, - data: { - status: 'completed', - completedAt: new Date(), - durationMs: run.startedAt ? Date.now() - run.startedAt.getTime() : 0, - screenshotUrl: screenshotKey, - evaluationStatus: result.evaluationStatus ?? null, - evaluationReason: result.evaluationReason ?? null, - }, - }); - - return { - success: true, - screenshotUrl: presignedUrl, - evaluationStatus: result.evaluationStatus, - evaluationReason: result.evaluationReason, - }; - } catch (err) { - this.logger.error('Failed to execute automation on session', err); - const { userFacing, needsReauth } = toRunErrorMessage(err); - - await db.browserAutomationRun.update({ - where: { id: runId }, - data: { - status: 'failed', - completedAt: new Date(), - durationMs: run.startedAt ? Date.now() - run.startedAt.getTime() : 0, - error: userFacing, - }, - }); - - return { - success: false, - error: userFacing, - needsReauth: needsReauth ? true : undefined, - }; - } - } - - async runBrowserAutomation( - automationId: string, - organizationId: string, - ): Promise<{ - runId: string; - success: boolean; - screenshotUrl?: string; - evaluationStatus?: 'pass' | 'fail'; - evaluationReason?: string; - error?: string; - needsReauth?: boolean; - }> { - // Get the automation with task context - const automation = await db.browserAutomation.findUnique({ - where: { id: automationId }, - include: { - task: { - select: { title: true, description: true }, - }, - }, - }); - - if (!automation) { - throw new Error('Automation not found'); - } - - // Get org context - const context = await this.getOrgContext(organizationId); - if (!context) { - return { - runId: '', - success: false, - needsReauth: true, - error: 'No browser context found. Please connect your browser first.', - }; - } - - // Create a run record - const run = await db.browserAutomationRun.create({ - data: { - automationId, - status: 'running', - startedAt: new Date(), - }, - }); - - try { - // Create a session - const { sessionId } = await this.createSessionWithContext( - context.contextId, - ); - - try { - const result = await this.executeAutomation( - sessionId, - automation.targetUrl, - automation.instruction, - { - title: automation.task.title, - description: automation.task.description, - evaluationCriteria: automation.evaluationCriteria, - }, - ); - - if (!result.success) { - // Update run as failed - include evaluation data if requirement not met - await db.browserAutomationRun.update({ - where: { id: run.id }, - data: { - status: 'failed', - completedAt: new Date(), - durationMs: Date.now() - run.startedAt!.getTime(), - error: result.error, - evaluationStatus: result.evaluationStatus, - evaluationReason: result.evaluationReason, - }, - }); - - return { - runId: run.id, - success: false, - error: result.error, - evaluationStatus: result.evaluationStatus, - evaluationReason: result.evaluationReason, - needsReauth: result.needsReauth, - }; - } - - // Upload screenshot to S3 (only taken if evaluation passed) - let screenshotKey: string | undefined; - let presignedUrl: string | undefined; - if (result.screenshot) { - screenshotKey = await this.uploadScreenshot( - organizationId, - automationId, - run.id, - result.screenshot, - ); - presignedUrl = await this.getPresignedUrl(screenshotKey); - } - - // Update run as completed. Only persist an evaluation verdict when - // the automation had criteria configured. - await db.browserAutomationRun.update({ - where: { id: run.id }, - data: { - status: 'completed', - completedAt: new Date(), - durationMs: Date.now() - run.startedAt!.getTime(), - screenshotUrl: screenshotKey, - evaluationStatus: result.evaluationStatus ?? null, - evaluationReason: result.evaluationReason ?? null, - }, - }); - - return { - runId: run.id, - success: true, - screenshotUrl: presignedUrl, - evaluationStatus: result.evaluationStatus, - evaluationReason: result.evaluationReason, - }; - } finally { - // Always attempt to close the session, but never let cleanup override success - try { - await this.closeSession(sessionId); - } catch (err) { - this.logger.warn('Failed to close Browserbase session (ignored)', { - sessionId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - } catch (err) { - this.logger.error('Failed to run browser automation', err); - const { userFacing, needsReauth } = toRunErrorMessage(err); - - // Update run as failed - await db.browserAutomationRun.update({ - where: { id: run.id }, - data: { - status: 'failed', - completedAt: new Date(), - durationMs: run.startedAt ? Date.now() - run.startedAt.getTime() : 0, - error: userFacing, - }, - }); - - return { - runId: run.id, - success: false, - error: userFacing, - needsReauth: needsReauth ? true : undefined, - }; - } - } - - private async executeAutomation( - sessionId: string, - targetUrl: string, - instruction: string, - taskContext?: { - title: string; - description?: string | null; - evaluationCriteria?: string | null; - }, - ): Promise<{ - success: boolean; - screenshot?: string; - evaluationStatus?: 'pass' | 'fail'; - evaluationReason?: string; - error?: string; - needsReauth?: boolean; - }> { - const stagehand = await this.createStagehand(sessionId); - - try { - let page = await this.ensureActivePage(stagehand); - - // Navigate to target URL - await page.goto(targetUrl, { - waitUntil: 'domcontentloaded', - timeoutMs: 30000, - }); - await delay(1000); - - // Check if we need to authenticate (look for login page indicators) - const loginSchema = z.object({ - isLoggedIn: z.boolean(), - }); - const authCheck = (await stagehand.extract( - 'Check if the user is logged in to this website. Look for a user avatar, profile menu, account dropdown, or login/sign-in buttons. Return true if logged in, false if you see login buttons or a login form.', - loginSchema as any, - )) as { isLoggedIn: boolean }; - - if (!authCheck.isLoggedIn) { - return { - success: false, - needsReauth: true, - error: 'Session expired. Please re-authenticate in browser settings.', - }; - } - - // Execute the navigation instruction using Stagehand agent - const fullInstruction = `${instruction}. After completing all navigation steps, stop and wait.`; - - await stagehand - .agent({ - cua: true, - model: { - modelName: STAGEHAND_CUA_MODEL, - apiKey: process.env.ANTHROPIC_API_KEY, - }, - }) - .execute({ - instruction: fullInstruction, - maxSteps: 20, - }); - - // Wait for final page to settle - await delay(2000); - - // Always take a screenshot at the end (no pass/fail criteria gate) - page = await this.ensureActivePage(stagehand); - const sourceUrl = page.url(); - const rawScreenshot = await page.screenshot({ - type: 'jpeg', - quality: 80, - fullPage: false, - }); - - let finalBuffer: Buffer = rawScreenshot; - try { - finalBuffer = await renderOverlay({ - buffer: rawScreenshot, - instruction, - sourceUrl, - capturedAt: new Date(), - }); - } catch (overlayErr) { - this.logger.warn( - 'Screenshot overlay render failed; uploading raw image', - { - error: - overlayErr instanceof Error - ? overlayErr.message - : String(overlayErr), - }, - ); - } - - // Optional evaluation: if the automation was configured with - // natural-language criteria, ask Stagehand to inspect the page and - // produce a pass/fail verdict with a short reason. - let evaluationStatus: 'pass' | 'fail' | undefined; - let evaluationReason: string | undefined; - const criteria = taskContext?.evaluationCriteria?.trim(); - if (criteria) { - try { - const evalSchema = z.object({ - pass: z.boolean(), - reason: z.string(), - }); - const evaluation = (await stagehand.extract( - `You are an auditor reviewing the current page after an automation has finished navigating. Decide whether the page clearly satisfies this criteria. Only return pass=true if the evidence is unambiguously present and visible. If it is ambiguous, missing, or contradicted, return pass=false. Always provide a short reason (max 220 characters).\n\nCriteria: ${criteria}`, - evalSchema as any, - )) as { pass: boolean; reason: string }; - - evaluationStatus = evaluation.pass ? 'pass' : 'fail'; - evaluationReason = evaluation.reason; - } catch (evalErr) { - this.logger.warn( - 'Evaluation step failed; returning screenshot without verdict', - { - error: - evalErr instanceof Error ? evalErr.message : String(evalErr), - }, - ); - } - } - - return { - success: true, - screenshot: finalBuffer.toString('base64'), - evaluationStatus, - evaluationReason, - }; - } catch (err) { - this.logger.error('Failed to execute automation', err); - const { userFacing, needsReauth } = toRunErrorMessage(err); - return { - success: false, - needsReauth: needsReauth ? true : undefined, - error: userFacing, - }; - } finally { - await this.safeCloseStagehand(stagehand); - } - } - - private async uploadScreenshot( - organizationId: string, - automationId: string, - runId: string, - base64Screenshot: string, - ): Promise { - const buffer = Buffer.from(base64Screenshot, 'base64'); - const key = `browser-automations/${organizationId}/${automationId}/${runId}.jpg`; - - await this.s3Client.send( - new PutObjectCommand({ - Bucket: this.bucketName, - Key: key, - Body: buffer, - ContentType: 'image/jpeg', - }), + ) { + return this.automationExecution.executeAutomationOnSession( + automationId, + runId, + sessionId, + organizationId, ); - - // Return just the key - we'll generate presigned URLs when viewing - return key; } - async getPresignedUrl( - key: string, - options: { expiresIn?: number; responseContentDisposition?: string } = {}, - ): Promise { - const command = new GetObjectCommand({ - Bucket: this.bucketName, - Key: key, - ResponseContentDisposition: options.responseContentDisposition, - }); - return getSignedUrl(this.s3Client, command, { - expiresIn: options.expiresIn ?? 3600, - }); + async runBrowserAutomation(automationId: string, organizationId: string) { + return this.automationExecution.runBrowserAutomation( + automationId, + organizationId, + ); } - /** - * Resolve a run's S3 screenshot key to a freshly signed presigned URL, - * scoped to the caller's organization. Used by the controller's - * GET runs/:runId/screenshot redirect endpoint so that the "Open full size" - * UI link never serves an expired URL. - * - * When `download` is true, the presigned URL is signed with an - * attachment Content-Disposition so the browser downloads the image - * instead of rendering it inline. - */ async getScreenshotRedirectUrl(input: { runId: string; organizationId: string; download?: boolean; }): Promise { - const { runId, organizationId, download } = input; - - const run = await db.browserAutomationRun.findUnique({ - where: { id: runId }, - include: { automation: { include: { task: true } } }, - }); - - if (!run || !run.screenshotUrl) { - throw new NotFoundException('Screenshot not found'); - } - - if (run.automation.task.organizationId !== organizationId) { - throw new NotFoundException('Screenshot not found'); - } - - const responseContentDisposition = download - ? `attachment; filename="screenshot-${runId}.jpg"` - : undefined; - - return this.getPresignedUrl(run.screenshotUrl, { - responseContentDisposition, - }); + return this.screenshots.getScreenshotRedirectUrl(input); } - async getRunWithPresignedUrl(runId: string) { - const run = await db.browserAutomationRun.findUnique({ - where: { id: runId }, - }); - - if (!run) return null; - - if (run.screenshotUrl) { - const presignedUrl = await this.getPresignedUrl(run.screenshotUrl); - return { ...run, screenshotUrl: presignedUrl }; - } - - return run; + async getRunWithPresignedUrl(runId: string, organizationId?: string) { + return this.automationCrud.getRunWithPresignedUrl(runId, organizationId); } - async getAutomationsWithPresignedUrls(taskId: string) { - const automations = await this.getBrowserAutomationsForTask(taskId); - - return Promise.all( - automations.map(async (automation) => { - const runsWithUrls = await Promise.all( - automation.runs.map(async (run) => { - if (run.screenshotUrl) { - const presignedUrl = await this.getPresignedUrl( - run.screenshotUrl, - ); - return { ...run, screenshotUrl: presignedUrl }; - } - return run; - }), - ); - return { ...automation, runs: runsWithUrls }; - }), + async getAutomationsWithPresignedUrls(taskId: string, organizationId?: string) { + return this.automationCrud.getAutomationsWithPresignedUrls( + taskId, + organizationId, ); } - // ===== Run History ===== + async getAutomationRuns( + automationId: string, + limit = 20, + organizationId?: string, + ) { + return this.automationCrud.getAutomationRuns( + automationId, + limit, + organizationId, + ); + } - async getAutomationRuns(automationId: string, limit = 20) { - return db.browserAutomationRun.findMany({ - where: { automationId }, - orderBy: { createdAt: 'desc' }, - take: limit, - }); + async getAutomationRun(runId: string, organizationId?: string) { + return this.automationCrud.getAutomationRun(runId, organizationId); } - async getAutomationRun(runId: string) { - return db.browserAutomationRun.findUnique({ - where: { id: runId }, - }); + getHostnameFromUrl(url: string): string { + return normalizeHostnameFromUrl(url); } } diff --git a/apps/api/src/browserbase/credential-vault.ts b/apps/api/src/browserbase/credential-vault.ts new file mode 100644 index 0000000000..aa9f111411 --- /dev/null +++ b/apps/api/src/browserbase/credential-vault.ts @@ -0,0 +1,30 @@ +export interface RuntimeCredentialMaterial { + username?: string; + password?: string; + totpCode?: string; +} + +export const BROWSER_CREDENTIAL_VAULT_ADAPTER = + 'BROWSER_CREDENTIAL_VAULT_ADAPTER'; + +export interface BrowserCredentialVaultAdapter { + resolveCredentialReference(params: { + profileId: string; + provider?: string | null; + externalItemRef?: string | null; + connectionId?: string | null; + }): Promise; +} + +export class NoopBrowserCredentialVaultAdapter + implements BrowserCredentialVaultAdapter +{ + async resolveCredentialReference(_params: { + profileId: string; + provider?: string | null; + externalItemRef?: string | null; + connectionId?: string | null; + }): Promise { + return null; + } +} diff --git a/apps/api/src/browserbase/dto/browserbase.dto.ts b/apps/api/src/browserbase/dto/browserbase.dto.ts index 2a7334a782..1fb7b29b18 100644 --- a/apps/api/src/browserbase/dto/browserbase.dto.ts +++ b/apps/api/src/browserbase/dto/browserbase.dto.ts @@ -54,6 +54,136 @@ export class CloseSessionDto { sessionId: string; } +export class AuthStatusResponseDto { + @ApiProperty() + isLoggedIn: boolean; + + @ApiPropertyOptional() + username?: string; +} + +// ===== Auth Profile DTOs ===== + +export class ResolveAuthProfileDto { + @ApiProperty({ description: 'Website URL to normalize into an auth profile hostname' }) + @IsUrl({}, { message: 'url must be a valid URL' }) + @IsSafeUrl({ message: 'The provided URL is not allowed.' }) + @IsString() + @IsNotEmpty() + url: string; + + @ApiPropertyOptional({ description: 'Human-readable profile name' }) + @IsString() + @IsOptional() + displayName?: string; + + @ApiPropertyOptional({ + description: 'Login identity label, such as a service account email', + }) + @IsString() + @IsOptional() + loginIdentity?: string; + + @ApiPropertyOptional({ description: 'External vault provider name' }) + @IsString() + @IsOptional() + vaultProvider?: string; + + @ApiPropertyOptional({ description: 'External vault item reference' }) + @IsString() + @IsOptional() + vaultExternalItemRef?: string; + + @ApiPropertyOptional({ description: 'External vault connection ID' }) + @IsString() + @IsOptional() + vaultConnectionId?: string; +} + +export class VerifyAuthProfileSessionDto { + @ApiProperty({ description: 'Browserbase session ID' }) + @IsString() + @IsNotEmpty() + sessionId: string; + + @ApiProperty({ description: 'URL to verify authentication on' }) + @IsUrl({}, { message: 'url must be a valid URL' }) + @IsSafeUrl({ message: 'The provided URL is not allowed.' }) + @IsString() + @IsNotEmpty() + url: string; +} + +export class MarkAuthProfileNeedsReauthDto { + @ApiPropertyOptional({ description: 'Reason the profile needs re-authentication' }) + @IsString() + @IsOptional() + reason?: string; +} + +export class BrowserAuthProfileResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + organizationId: string; + + @ApiProperty() + hostname: string; + + @ApiProperty() + loginIdentity: string; + + @ApiProperty() + displayName: string; + + @ApiProperty() + contextId: string; + + @ApiProperty({ enum: ['unverified', 'verified', 'needs_reauth', 'blocked'] }) + status: string; + + @ApiPropertyOptional() + lastVerifiedAt?: Date; + + @ApiPropertyOptional() + lastAuthCheckUrl?: string; + + @ApiPropertyOptional() + blockedReason?: string; + + @ApiPropertyOptional() + vaultProvider?: string; + + @ApiPropertyOptional() + vaultExternalItemRef?: string; + + @ApiPropertyOptional() + vaultConnectionId?: string; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} + +export class ResolveAuthProfileResponseDto { + @ApiProperty({ type: BrowserAuthProfileResponseDto }) + profile: BrowserAuthProfileResponseDto; + + @ApiProperty() + isNew: boolean; +} + +export class VerifyAuthProfileResponseDto { + @ApiProperty({ type: BrowserAuthProfileResponseDto }) + profile: BrowserAuthProfileResponseDto; + + @ApiProperty({ type: () => AuthStatusResponseDto }) + auth: AuthStatusResponseDto; +} + // ===== Browser Automation DTOs ===== export class CreateBrowserAutomationDto { @@ -146,6 +276,18 @@ export class UpdateBrowserAutomationDto { scheduleFrequency?: TaskFrequency; } +export class ExecuteAutomationSessionDto { + @ApiProperty({ description: 'Browser automation run ID' }) + @IsString() + @IsNotEmpty() + runId: string; + + @ApiProperty({ description: 'Browserbase session ID' }) + @IsString() + @IsNotEmpty() + sessionId: string; +} + // ===== Response DTOs ===== export class ContextResponseDto { @@ -164,14 +306,6 @@ export class SessionResponseDto { liveViewUrl: string; } -export class AuthStatusResponseDto { - @ApiProperty() - isLoggedIn: boolean; - - @ApiPropertyOptional() - username?: string; -} - export class BrowserAutomationResponseDto { @ApiProperty() id: string; @@ -208,6 +342,9 @@ export class BrowserAutomationRunResponseDto { @ApiProperty() automationId: string; + @ApiPropertyOptional() + profileId?: string; + @ApiProperty() status: string; @@ -226,6 +363,21 @@ export class BrowserAutomationRunResponseDto { @ApiPropertyOptional() error?: string; + @ApiPropertyOptional() + failureCode?: string; + + @ApiPropertyOptional() + failureStage?: string; + + @ApiPropertyOptional() + blockedReason?: string; + + @ApiPropertyOptional() + finalUrl?: string; + + @ApiPropertyOptional() + attemptCount?: number; + @ApiProperty() createdAt: Date; } @@ -245,4 +397,19 @@ export class RunAutomationResponseDto { @ApiPropertyOptional() needsReauth?: boolean; + + @ApiPropertyOptional() + evaluationStatus?: string; + + @ApiPropertyOptional() + evaluationReason?: string; + + @ApiPropertyOptional() + failureCode?: string; + + @ApiPropertyOptional() + failureStage?: string; + + @ApiPropertyOptional() + blockedReason?: string; } diff --git a/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts b/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts index d03c50c9a8..9312f90950 100644 --- a/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts +++ b/apps/api/src/cloud-security/providers/gcp-security.service.spec.ts @@ -822,4 +822,81 @@ describe('GCPSecurityService.scanSecurityFindings — all-scopes-failed guard', expect(findings).toEqual([]); }); + + // Regression: SCC public-bucket findings (PUBLIC_BUCKET_ACL) sometimes come + // back with an empty `finding.resourceName` while the resource identity lives + // on `result.resource.name`. Storing resourceId: "" made "Mark as exception" + // throw 'This finding cannot be marked as an exception — it lacks a stable + // check/resource identity'. The mapping must fall back to result.resource.name + // so the finding keeps a stable, non-empty resourceId. + it('falls back to result.resource.name for resourceId when finding.resourceName is empty (public bucket)', async () => { + fetchMock.mockResolvedValue( + sccPage([ + { + finding: { + name: 'organizations/1/sources/-/findings/pub-bucket-1', + category: 'PUBLIC_BUCKET_ACL', + description: 'Bucket is public', + severity: 'HIGH', + state: 'ACTIVE', + resourceName: '', // SCC omitted the per-finding resource name + eventTime: '2026-01-01T00:00:00Z', + createTime: '2026-01-01T00:00:00Z', + }, + resource: { + name: '//storage.googleapis.com/projects/acme/buckets/public-bucket', + type: 'storage.googleapis.com/Bucket', + projectDisplayName: 'acme', + }, + }, + ]), + ); + + const findings = await service.scanSecurityFindings( + { access_token: 'tok' }, + { project_ids: ['acme'] }, + ); + + expect(findings).toHaveLength(1); + // Pre-fix this was '' → resolveFindingForException rejected the finding. + expect(findings[0].resourceId).toBe( + '//storage.googleapis.com/projects/acme/buckets/public-bucket', + ); + }); + + // Regression: when SCC omits BOTH `finding.resourceName` AND + // `result.resource.name`, the old `|| ''` tail left resourceId empty — the + // exact state the exception resolver rejects. The chain must bottom out at + // `finding.name` (the finding's canonical id), which SCC always populates, + // so resourceId is guaranteed non-empty. + it('falls back to finding.name and never leaves resourceId empty when both resourceName and resource.name are absent', async () => { + fetchMock.mockResolvedValue( + sccPage([ + { + finding: { + name: 'organizations/1/sources/-/findings/no-resource-1', + category: 'PUBLIC_BUCKET_ACL', + description: 'Bucket is public', + severity: 'HIGH', + state: 'ACTIVE', + resourceName: '', // SCC omitted the per-finding resource name + eventTime: '2026-01-01T00:00:00Z', + createTime: '2026-01-01T00:00:00Z', + }, + // No `resource` block at all → result.resource?.name is undefined. + }, + ]), + ); + + const findings = await service.scanSecurityFindings( + { access_token: 'tok' }, + { project_ids: ['acme'] }, + ); + + expect(findings).toHaveLength(1); + expect(findings[0].resourceId).not.toBe(''); + expect(findings[0].resourceId).toBe( + 'organizations/1/sources/-/findings/no-resource-1', + ); + }); }); diff --git a/apps/api/src/cloud-security/providers/gcp-security.service.ts b/apps/api/src/cloud-security/providers/gcp-security.service.ts index a034bb6b4d..f560d5b138 100644 --- a/apps/api/src/cloud-security/providers/gcp-security.service.ts +++ b/apps/api/src/cloud-security/providers/gcp-security.service.ts @@ -1415,7 +1415,15 @@ export class GCPSecurityService { f.description || `Security finding: ${f.category}`, severity: this.mapSeverity(f.severity), resourceType: result.resource?.type ?? 'gcp-resource', - resourceId: f.resourceName, + // SCC sometimes omits the per-finding `resourceName` (notably + // PUBLIC_BUCKET_ACL findings) while the stable identity still + // lives on `result.resource.name`. If both are absent, fall back + // to `f.name` — the finding's own canonical id (also used for + // dedup above and as `id`), which SCC always populates. Ending + // the chain there keeps resourceId guaranteed non-empty so the + // finding can be marked as an exception (an empty resourceId is + // rejected by the resolver). + resourceId: f.resourceName || result.resource?.name || f.name, remediation, evidence: { findingKey, diff --git a/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts b/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts index bd4c5b2b6b..322b75664e 100644 --- a/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts +++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts @@ -57,6 +57,39 @@ describe('buildManifestForFramework', () => { expect(manifest.tasks).toHaveLength(1); }); + // FRAME-18: the frozen manifest must order requirements by sortOrder, then by + // the stable identifier key, then name. Tiebreaking by name alone would let a + // published manifest reorder unsorted requirements whenever a name is edited. + it('requests canonical requirement ordering (sortOrder → identifier → name) and preserves sortOrder', async () => { + (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_csf', + name: 'NIST CSF', + version: '2.0', + description: null, + requirements: [ + { + id: 'frk_rq_gv', + identifier: 'GV.OC-01', + name: 'Organizational Context', + description: 'd', + sortOrder: 10, + controlTemplates: [], + }, + ], + }); + + const manifest = await buildManifestForFramework('frk_csf'); + + const findUniqueArg = (db.frameworkEditorFramework.findUnique as jest.Mock).mock + .calls[0][0]; + expect(findUniqueArg.include.requirements.orderBy).toEqual([ + { sortOrder: { sort: 'asc', nulls: 'last' } }, + { identifier: 'asc' }, + { name: 'asc' }, + ]); + expect(manifest.requirements[0].sortOrder).toBe(10); + }); + it('dedupes controls/policies/tasks that appear under multiple requirements', async () => { (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ id: 'frk_iso', diff --git a/apps/api/src/framework-editor-versions/framework-manifest-builder.ts b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts index 4f429634b2..36411ebdbb 100644 --- a/apps/api/src/framework-editor-versions/framework-manifest-builder.ts +++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts @@ -12,6 +12,16 @@ export async function buildManifestForFramework(frameworkId: string): Promise ({ + FrameworkEditorFrameworkFamilyStatus: { + visible: 'visible', + hidden: 'hidden', + under_construction: 'under_construction', + partial: 'partial', + }, +})); + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { UpdateFrameworkFamilyDto } from './update-framework-family.dto'; + +function toDto(plain: Record): UpdateFrameworkFamilyDto { + return plainToInstance(UpdateFrameworkFamilyDto, plain, { + enableImplicitConversion: true, + }); +} + +describe('UpdateFrameworkFamilyDto', () => { + it('accepts an empty payload (every field is optional)', async () => { + const errors = await validate(toDto({}), { whitelist: true, forbidNonWhitelisted: true }); + expect(errors).toHaveLength(0); + }); + + it('accepts a partial valid update', async () => { + const errors = await validate(toDto({ name: 'NIST', status: 'partial' }), { + whitelist: true, + forbidNonWhitelisted: true, + }); + expect(errors).toHaveLength(0); + }); + + // Regression: null must be rejected, not silently passed to a non-nullable column. + it('rejects null name', async () => { + const errors = await validate(toDto({ name: null }), { whitelist: true }); + expect(errors.some((e) => e.property === 'name')).toBe(true); + }); + + it('rejects null status', async () => { + const errors = await validate(toDto({ status: null }), { whitelist: true }); + expect(errors.some((e) => e.property === 'status')).toBe(true); + }); + + it('rejects an empty-string name', async () => { + const errors = await validate(toDto({ name: '' }), { whitelist: true }); + expect(errors.some((e) => e.property === 'name')).toBe(true); + }); + + it('rejects an invalid status value', async () => { + const errors = await validate(toDto({ status: 'bogus' }), { whitelist: true }); + expect(errors.some((e) => e.property === 'status')).toBe(true); + }); +}); diff --git a/apps/api/src/framework-editor/framework-family/dto/update-framework-family.dto.ts b/apps/api/src/framework-editor/framework-family/dto/update-framework-family.dto.ts new file mode 100644 index 0000000000..327e89a912 --- /dev/null +++ b/apps/api/src/framework-editor/framework-family/dto/update-framework-family.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { FrameworkEditorFrameworkFamilyStatus } from '@db'; +import { IsEnum, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator'; + +// Each field is optional (may be omitted) but must NOT be null when present — +// @ValidateIf runs the validators whenever the key is sent (incl. null), so a +// null reaches @IsString/@IsEnum and is rejected with a 400 instead of slipping +// through to a non-nullable Prisma column. (@IsOptional() would skip null.) +export class UpdateFrameworkFamilyDto { + @ApiPropertyOptional() + @ValidateIf((o) => o.name !== undefined) + @IsString() + @MinLength(1) + @MaxLength(255) + name?: string; + + @ApiPropertyOptional() + @ValidateIf((o) => o.description !== undefined) + @IsString() + @MaxLength(2000) + description?: string; + + @ApiPropertyOptional({ enum: FrameworkEditorFrameworkFamilyStatus }) + @ValidateIf((o) => o.status !== undefined) + @IsEnum(FrameworkEditorFrameworkFamilyStatus) + status?: FrameworkEditorFrameworkFamilyStatus; +} diff --git a/apps/api/src/framework-editor/framework-family/framework-family.controller.ts b/apps/api/src/framework-editor/framework-family/framework-family.controller.ts new file mode 100644 index 0000000000..663687fdf1 --- /dev/null +++ b/apps/api/src/framework-editor/framework-family/framework-family.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { CreateFrameworkFamilyDto } from './dto/create-framework-family.dto'; +import { MoveFrameworksDto } from './dto/move-frameworks.dto'; +import { UpdateFrameworkFamilyDto } from './dto/update-framework-family.dto'; +import { FrameworkFamilyService } from './framework-family.service'; + +@ApiTags('Framework Editor Framework Families') +@Controller({ path: 'framework-editor/framework-family', version: '1' }) +@UseGuards(PlatformAdminGuard) +export class FrameworkFamilyController { + constructor(private readonly service: FrameworkFamilyService) {} + + @Get() + @ApiOperation({ summary: 'List framework families with framework counts' }) + async findAll() { + return this.service.findAll(); + } + + @Post() + @ApiOperation({ summary: 'Create a framework family' }) + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async create(@Body() dto: CreateFrameworkFamilyDto) { + return this.service.create(dto); + } + + @Post('move') + @ApiOperation({ summary: 'Move frameworks into a family (or to the root)' }) + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async move(@Body() dto: MoveFrameworksDto) { + return this.service.moveFrameworks(dto.frameworkIds, dto.familyId ?? null); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a framework family' }) + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async update(@Param('id') id: string, @Body() dto: UpdateFrameworkFamilyDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a framework family (must be empty)' }) + async delete(@Param('id') id: string) { + return this.service.delete(id); + } +} diff --git a/apps/api/src/framework-editor/framework-family/framework-family.module.ts b/apps/api/src/framework-editor/framework-family/framework-family.module.ts new file mode 100644 index 0000000000..0c0871f6bb --- /dev/null +++ b/apps/api/src/framework-editor/framework-family/framework-family.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { FrameworkFamilyController } from './framework-family.controller'; +import { FrameworkFamilyService } from './framework-family.service'; + +@Module({ + imports: [AuthModule], + controllers: [FrameworkFamilyController], + providers: [FrameworkFamilyService], + exports: [FrameworkFamilyService], +}) +export class FrameworkFamilyModule {} diff --git a/apps/api/src/framework-editor/framework-family/framework-family.service.spec.ts b/apps/api/src/framework-editor/framework-family/framework-family.service.spec.ts new file mode 100644 index 0000000000..3b6a819052 --- /dev/null +++ b/apps/api/src/framework-editor/framework-family/framework-family.service.spec.ts @@ -0,0 +1,159 @@ +jest.mock('@db', () => ({ + db: { + frameworkEditorFrameworkFamily: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, + frameworkEditorFramework: { + updateMany: jest.fn(), + }, + }, + FrameworkEditorFrameworkFamilyStatus: { + visible: 'visible', + hidden: 'hidden', + under_construction: 'under_construction', + partial: 'partial', + }, +})); + +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { FrameworkFamilyService } from './framework-family.service'; + +const mockDb = db as jest.Mocked; +const familyDb = mockDb.frameworkEditorFrameworkFamily as unknown as Record; +const frameworkDb = mockDb.frameworkEditorFramework as unknown as Record; + +describe('FrameworkFamilyService', () => { + let service: FrameworkFamilyService; + + beforeEach(() => { + service = new FrameworkFamilyService(); + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('maps the framework _count into frameworksCount and strips _count', async () => { + familyDb.findMany.mockResolvedValue([ + { id: 'frk_fam_1', name: 'NIST', _count: { frameworks: 3 } }, + ]); + const result = await service.findAll(); + expect(result[0]).toEqual({ id: 'frk_fam_1', name: 'NIST', frameworksCount: 3, _count: undefined }); + }); + }); + + describe('create', () => { + it('defaults description to "" and status to hidden when omitted', async () => { + familyDb.create.mockResolvedValue({ id: 'frk_fam_new', name: 'X' }); + await service.create({ name: 'X' } as never); + expect(familyDb.create.mock.calls[0][0].data).toEqual({ + name: 'X', + description: '', + status: 'hidden', + }); + }); + + it('persists a provided status and description', async () => { + familyDb.create.mockResolvedValue({ id: 'frk_fam_new', name: 'X' }); + await service.create({ name: 'X', description: 'd', status: 'partial' } as never); + expect(familyDb.create.mock.calls[0][0].data).toEqual({ + name: 'X', + description: 'd', + status: 'partial', + }); + }); + }); + + describe('update', () => { + it('throws NotFound when the family does not exist', async () => { + familyDb.findUnique.mockResolvedValue(null); + await expect(service.update('missing', { name: 'Y' } as never)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('only writes provided fields', async () => { + familyDb.findUnique.mockResolvedValue({ id: 'frk_fam_1', _count: { frameworks: 0 } }); + familyDb.update.mockResolvedValue({ id: 'frk_fam_1', name: 'Y' }); + await service.update('frk_fam_1', { name: 'Y' } as never); + expect(familyDb.update.mock.calls[0][0].data).toEqual({ name: 'Y' }); + }); + + it('ignores explicit null fields (does not write null to non-nullable columns)', async () => { + familyDb.findUnique.mockResolvedValue({ id: 'frk_fam_1', _count: { frameworks: 0 } }); + familyDb.update.mockResolvedValue({ id: 'frk_fam_1' }); + await service.update('frk_fam_1', { + name: null, + description: null, + status: null, + } as never); + expect(familyDb.update.mock.calls[0][0].data).toEqual({}); + }); + }); + + describe('delete', () => { + it('refuses to delete a non-empty family (atomic conditional delete is a no-op)', async () => { + familyDb.findUnique.mockResolvedValue({ + id: 'frk_fam_1', + name: 'NIST', + _count: { frameworks: 2 }, + }); + familyDb.deleteMany.mockResolvedValue({ count: 0 }); + await expect(service.delete('frk_fam_1')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('atomically deletes only when empty (frameworks: none filter)', async () => { + familyDb.findUnique.mockResolvedValue({ + id: 'frk_fam_1', + name: 'NIST', + _count: { frameworks: 0 }, + }); + familyDb.deleteMany.mockResolvedValue({ count: 1 }); + await service.delete('frk_fam_1'); + expect(familyDb.deleteMany).toHaveBeenCalledWith({ + where: { id: 'frk_fam_1', frameworks: { none: {} } }, + }); + }); + + it('throws NotFound for a missing family', async () => { + familyDb.findUnique.mockResolvedValue(null); + await expect(service.delete('missing')).rejects.toBeInstanceOf(NotFoundException); + expect(familyDb.deleteMany).not.toHaveBeenCalled(); + }); + }); + + describe('moveFrameworks', () => { + it('validates the destination family exists before moving', async () => { + familyDb.findUnique.mockResolvedValue(null); + await expect(service.moveFrameworks(['frk_1'], 'frk_fam_missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(frameworkDb.updateMany).not.toHaveBeenCalled(); + }); + + it('moves frameworks into a family', async () => { + familyDb.findUnique.mockResolvedValue({ id: 'frk_fam_1' }); + frameworkDb.updateMany.mockResolvedValue({ count: 2 }); + const result = await service.moveFrameworks(['frk_1', 'frk_2'], 'frk_fam_1'); + expect(frameworkDb.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['frk_1', 'frk_2'] } }, + data: { familyId: 'frk_fam_1' }, + }); + expect(result).toEqual({ count: 2 }); + }); + + it('moves frameworks to the root (null) without a family lookup', async () => { + frameworkDb.updateMany.mockResolvedValue({ count: 1 }); + await service.moveFrameworks(['frk_1'], null); + expect(familyDb.findUnique).not.toHaveBeenCalled(); + expect(frameworkDb.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['frk_1'] } }, + data: { familyId: null }, + }); + }); + }); +}); diff --git a/apps/api/src/framework-editor/framework-family/framework-family.service.ts b/apps/api/src/framework-editor/framework-family/framework-family.service.ts new file mode 100644 index 0000000000..912e8fafbf --- /dev/null +++ b/apps/api/src/framework-editor/framework-family/framework-family.service.ts @@ -0,0 +1,113 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { db, FrameworkEditorFrameworkFamilyStatus } from '@db'; +import { CreateFrameworkFamilyDto } from './dto/create-framework-family.dto'; +import { UpdateFrameworkFamilyDto } from './dto/update-framework-family.dto'; + +@Injectable() +export class FrameworkFamilyService { + private readonly logger = new Logger(FrameworkFamilyService.name); + + /** List families alphabetically with the number of frameworks in each. */ + async findAll() { + const families = await db.frameworkEditorFrameworkFamily.findMany({ + orderBy: { name: 'asc' }, + include: { _count: { select: { frameworks: true } } }, + }); + return families.map((family) => ({ + ...family, + frameworksCount: family._count.frameworks, + _count: undefined, + })); + } + + async create(dto: CreateFrameworkFamilyDto) { + const family = await db.frameworkEditorFrameworkFamily.create({ + data: { + name: dto.name, + description: dto.description ?? '', + status: dto.status ?? FrameworkEditorFrameworkFamilyStatus.hidden, + }, + }); + this.logger.log(`Created framework family: ${family.name} (${family.id})`); + return family; + } + + async update(id: string, dto: UpdateFrameworkFamilyDto) { + await this.getOrThrow(id); + const updated = await db.frameworkEditorFrameworkFamily.update({ + where: { id }, + data: { + // `!= null` (not `!== undefined`) so an explicit null is ignored rather + // than written to these non-nullable columns (which would 500). + ...(dto.name != null && { name: dto.name }), + ...(dto.description != null && { description: dto.description }), + ...(dto.status != null && { status: dto.status }), + }, + }); + this.logger.log(`Updated framework family: ${updated.name} (${id})`); + return updated; + } + + /** + * Delete a family — only allowed once it contains no frameworks. + * + * The emptiness check and the delete are a single atomic statement + * (`deleteMany` with a `frameworks: { none: {} }` predicate the DB evaluates + * at delete time), so a concurrent move that adds a framework can't slip + * between a check and the delete: it simply makes this a no-op (count 0). The + * RESTRICT FK is a second backstop. + */ + async delete(id: string) { + const family = await this.getOrThrow(id); + const { count } = await db.frameworkEditorFrameworkFamily.deleteMany({ + where: { id, frameworks: { none: {} } }, + }); + if (count === 0) { + throw new BadRequestException( + `Cannot delete "${family.name}": move or remove its frameworks first.`, + ); + } + this.logger.log(`Deleted framework family ${id}`); + return { message: 'Framework family deleted successfully' }; + } + + /** + * Move frameworks into a family, or to the root when familyId is null. + * Validates the destination family exists before moving. + */ + async moveFrameworks(frameworkIds: string[], familyId: string | null) { + if (familyId !== null) { + const family = await db.frameworkEditorFrameworkFamily.findUnique({ + where: { id: familyId }, + select: { id: true }, + }); + if (!family) { + throw new NotFoundException(`Framework family ${familyId} not found`); + } + } + const { count } = await db.frameworkEditorFramework.updateMany({ + where: { id: { in: frameworkIds } }, + data: { familyId }, + }); + this.logger.log( + `Moved ${count} framework(s) to ${familyId ?? 'root'}`, + ); + return { count }; + } + + private async getOrThrow(id: string) { + const family = await db.frameworkEditorFrameworkFamily.findUnique({ + where: { id }, + include: { _count: { select: { frameworks: true } } }, + }); + if (!family) { + throw new NotFoundException(`Framework family ${id} not found`); + } + return family; + } +} diff --git a/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts b/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts index c5f3b91463..a20d31d2f1 100644 --- a/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts +++ b/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts @@ -73,6 +73,13 @@ class ImportRequirementDto { @IsOptional() @MaxLength(255) requirementFamily?: string; + + // FRAME-18: per-framework display order, preserved across export/import. + @ApiPropertyOptional({ nullable: true }) + @IsInt() + @Min(0) + @IsOptional() + sortOrder?: number | null; } class ImportControlTemplateDto { diff --git a/apps/api/src/framework-editor/framework/framework-export.service.ts b/apps/api/src/framework-editor/framework/framework-export.service.ts index da66a42d1c..cad02aa48a 100644 --- a/apps/api/src/framework-editor/framework/framework-export.service.ts +++ b/apps/api/src/framework-editor/framework/framework-export.service.ts @@ -22,6 +22,7 @@ export interface ExportedFramework { identifier: string; description: string; requirementFamily?: string | null; + sortOrder?: number | null; }>; controlTemplates: Array<{ name: string; @@ -63,7 +64,13 @@ export class FrameworkExportService { const requirements = await db.frameworkEditorRequirement.findMany({ where: { frameworkId }, - orderBy: { name: 'asc' }, + // FRAME-18: export in configured order; identifier (canonical key) then + // name as the secondary/tertiary tiebreak, matching the manifest builder. + orderBy: [ + { sortOrder: { sort: 'asc', nulls: 'last' } }, + { identifier: 'asc' }, + { name: 'asc' }, + ], }); const controlTemplates = await db.frameworkEditorControlTemplate.findMany({ @@ -131,6 +138,7 @@ export class FrameworkExportService { identifier: r.identifier, description: r.description, requirementFamily: r.requirementFamily || null, + sortOrder: r.sortOrder ?? null, })), controlTemplates: controlTemplates.map((ct) => ({ name: ct.name, @@ -194,6 +202,7 @@ export class FrameworkExportService { identifier: r.identifier ?? '', description: r.description, requirementFamily: r.requirementFamily || null, + sortOrder: r.sortOrder ?? null, }, }), ), diff --git a/apps/api/src/framework-editor/framework/framework.service.ts b/apps/api/src/framework-editor/framework/framework.service.ts index a75413541d..d2e4234863 100644 --- a/apps/api/src/framework-editor/framework/framework.service.ts +++ b/apps/api/src/framework-editor/framework/framework.service.ts @@ -53,7 +53,14 @@ export class FrameworkEditorFrameworkService { where: { id }, include: { requirements: { - orderBy: { name: 'asc' }, + // FRAME-18: surface requirements in the framework's configured order + // (numbered first, unset last), tiebreaking by identifier (the + // canonical-order key) then name, so the editor grid opens pre-sorted. + orderBy: [ + { sortOrder: { sort: 'asc', nulls: 'last' } }, + { identifier: 'asc' }, + { name: 'asc' }, + ], include: { controlTemplates: { select: { id: true, name: true } }, }, diff --git a/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts b/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts index 250da0f694..bea2a7b885 100644 --- a/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts +++ b/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts @@ -2,10 +2,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, + IsInt, IsNotEmpty, IsString, IsOptional, MaxLength, + Min, ValidateNested, } from 'class-validator'; @@ -38,6 +40,13 @@ class BatchUpdateRequirementItem { @IsOptional() @MaxLength(255) requirementFamily?: string; + + // Nullable so a batch update can clear an order back to "unset". + @ApiProperty({ required: false, nullable: true }) + @IsInt() + @Min(0) + @IsOptional() + sortOrder?: number | null; } export class BatchUpdateRequirementsDto { diff --git a/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.spec.ts b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.spec.ts index 693976958b..41576de3b0 100644 --- a/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.spec.ts +++ b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.spec.ts @@ -47,4 +47,29 @@ describe('CreateRequirementDto', () => { expect(errors.length).toBeGreaterThan(0); expect(errors[0].property).toBe('description'); }); + + // ── sortOrder (FRAME-18) ─────────────────────────────────────────── + it('accepts a non-negative integer sortOrder', async () => { + const dto = toDto({ ...VALID_BASE, sortOrder: 10 }); + const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true }); + expect(errors).toHaveLength(0); + }); + + it('accepts a payload with no sortOrder', async () => { + const dto = toDto(VALID_BASE); + const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true }); + expect(errors.some((e) => e.property === 'sortOrder')).toBe(false); + }); + + it('rejects a negative sortOrder', async () => { + const dto = toDto({ ...VALID_BASE, sortOrder: -1 }); + const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true }); + expect(errors.some((e) => e.property === 'sortOrder')).toBe(true); + }); + + it('rejects a non-integer sortOrder', async () => { + const dto = toDto({ ...VALID_BASE, sortOrder: 1.5 }); + const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true }); + expect(errors.some((e) => e.property === 'sortOrder')).toBe(true); + }); }); diff --git a/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts index 8c21671c00..479f379e26 100644 --- a/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts +++ b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; +import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Min } from 'class-validator'; export class CreateRequirementDto { @ApiProperty({ example: 'frk_abc123' }) @@ -30,4 +30,13 @@ export class CreateRequirementDto { @IsOptional() @MaxLength(255) requirementFamily?: string; + + @ApiPropertyOptional({ + example: 10, + description: 'Display order within the framework (lower sorts first).', + }) + @IsInt() + @Min(0) + @IsOptional() + sortOrder?: number; } diff --git a/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts b/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts index 705707934d..367bcdc0e3 100644 --- a/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts +++ b/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; export class UpdateRequirementDto { @ApiPropertyOptional() @@ -25,4 +25,12 @@ export class UpdateRequirementDto { @IsOptional() @MaxLength(255) requirementFamily?: string; + + // Nullable so the editor can clear an order back to "unset". @IsOptional() + // skips validation for both null and undefined, letting null pass through. + @ApiPropertyOptional({ nullable: true }) + @IsInt() + @Min(0) + @IsOptional() + sortOrder?: number | null; } diff --git a/apps/api/src/framework-editor/requirement/requirement.service.spec.ts b/apps/api/src/framework-editor/requirement/requirement.service.spec.ts new file mode 100644 index 0000000000..db45a26fc9 --- /dev/null +++ b/apps/api/src/framework-editor/requirement/requirement.service.spec.ts @@ -0,0 +1,135 @@ +jest.mock('@db', () => ({ + db: { + frameworkEditorRequirement: { + create: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + frameworkEditorFramework: { + findUnique: jest.fn(), + }, + $transaction: jest.fn(), + }, +})); + +import { db } from '@db'; +import { RequirementService } from './requirement.service'; + +const mockDb = db as jest.Mocked; + +describe('RequirementService — sortOrder (FRAME-18)', () => { + let service: RequirementService; + + beforeEach(() => { + service = new RequirementService(); + jest.clearAllMocks(); + (mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_1', + }); + (mockDb.frameworkEditorRequirement.create as jest.Mock).mockResolvedValue({ + id: 'frk_rq_new', + name: 'New', + }); + (mockDb.frameworkEditorRequirement.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_rq_1', + }); + (mockDb.frameworkEditorRequirement.update as jest.Mock).mockResolvedValue({ + id: 'frk_rq_1', + name: 'Updated', + }); + // batchUpdate wraps the per-row update() promises in a transaction. + (mockDb.$transaction as jest.Mock).mockImplementation((ops: Promise[]) => + Promise.all(ops), + ); + }); + + const createDataOf = () => + (mockDb.frameworkEditorRequirement.create as jest.Mock).mock.calls[0][0].data; + const updateDataOf = (i = 0) => + (mockDb.frameworkEditorRequirement.update as jest.Mock).mock.calls[i][0].data; + + describe('create', () => { + it('persists a provided sortOrder', async () => { + await service.create({ + frameworkId: 'frk_1', + name: 'R1', + description: 'd', + sortOrder: 5, + } as never); + + expect(createDataOf().sortOrder).toBe(5); + }); + + it('defaults sortOrder to null when omitted', async () => { + await service.create({ + frameworkId: 'frk_1', + name: 'R1', + description: 'd', + } as never); + + expect(createDataOf().sortOrder).toBeNull(); + }); + }); + + describe('update', () => { + it('persists a provided sortOrder', async () => { + await service.update('frk_rq_1', { sortOrder: 7 } as never); + expect(updateDataOf().sortOrder).toBe(7); + }); + + it('clears sortOrder when null is sent', async () => { + await service.update('frk_rq_1', { sortOrder: null } as never); + expect(updateDataOf().sortOrder).toBeNull(); + }); + + it('leaves sortOrder untouched when not provided', async () => { + await service.update('frk_rq_1', { name: 'Renamed' } as never); + expect(updateDataOf().sortOrder).toBeUndefined(); + }); + }); + + describe('batchUpdate', () => { + it('persists sortOrder for rows that include it (including null to clear)', async () => { + await service.batchUpdate([ + { id: 'a', sortOrder: 3 }, + { id: 'b', sortOrder: null }, + { id: 'c', name: 'NoOrder' }, + ]); + + expect(updateDataOf(0).sortOrder).toBe(3); + expect(updateDataOf(1).sortOrder).toBeNull(); + // Row without sortOrder must not include the key at all (untouched). + expect('sortOrder' in updateDataOf(2)).toBe(false); + }); + }); + + describe('findAll / findAllForFramework ordering', () => { + beforeEach(() => { + (mockDb.frameworkEditorRequirement.findMany as jest.Mock).mockResolvedValue([]); + }); + + it('orders by sortOrder ascending with nulls last, then name', async () => { + await service.findAll(); + const orderBy = (mockDb.frameworkEditorRequirement.findMany as jest.Mock).mock + .calls[0][0].orderBy; + expect(orderBy).toEqual([ + { sortOrder: { sort: 'asc', nulls: 'last' } }, + { identifier: 'asc' }, + { name: 'asc' }, + ]); + }); + + it('orders per-framework requirements the same way', async () => { + await service.findAllForFramework('frk_1'); + const orderBy = (mockDb.frameworkEditorRequirement.findMany as jest.Mock).mock + .calls[0][0].orderBy; + expect(orderBy).toEqual([ + { sortOrder: { sort: 'asc', nulls: 'last' } }, + { identifier: 'asc' }, + { name: 'asc' }, + ]); + }); + }); +}); diff --git a/apps/api/src/framework-editor/requirement/requirement.service.ts b/apps/api/src/framework-editor/requirement/requirement.service.ts index 42732802dd..7b1ab35409 100644 --- a/apps/api/src/framework-editor/requirement/requirement.service.ts +++ b/apps/api/src/framework-editor/requirement/requirement.service.ts @@ -11,7 +11,15 @@ export class RequirementService { return db.frameworkEditorRequirement.findMany({ take, skip, - orderBy: { name: 'asc' }, + // FRAME-18: numbered requirements first (ascending), unset rows last, + // then by identifier (the canonical-order key, e.g. CC6.1 / GV.OC-01), + // with name as a final stable tiebreak. Identifier — not name — matches + // the editor/app comparators and is stable when descriptive names change. + orderBy: [ + { sortOrder: { sort: 'asc', nulls: 'last' } }, + { identifier: 'asc' }, + { name: 'asc' }, + ], include: { framework: { select: { id: true, name: true } }, }, @@ -21,7 +29,11 @@ export class RequirementService { async findAllForFramework(frameworkId: string) { return db.frameworkEditorRequirement.findMany({ where: { frameworkId }, - orderBy: { name: 'asc' }, + orderBy: [ + { sortOrder: { sort: 'asc', nulls: 'last' } }, + { identifier: 'asc' }, + { name: 'asc' }, + ], include: { controlTemplates: { select: { id: true, name: true } }, }, @@ -43,6 +55,7 @@ export class RequirementService { identifier: dto.identifier ?? '', description: dto.description ?? '', requirementFamily: dto.requirementFamily || null, + sortOrder: dto.sortOrder ?? null, }, }); this.logger.log(`Created requirement: ${req.name} (${req.id})`); @@ -77,6 +90,7 @@ export class RequirementService { identifier?: string; description?: string; requirementFamily?: string; + sortOrder?: number | null; }>, ) { return db.$transaction( @@ -95,6 +109,8 @@ export class RequirementService { ...(data.requirementFamily !== undefined && { requirementFamily: data.requirementFamily || null, }), + // null clears the order; undefined leaves it untouched. + ...(data.sortOrder !== undefined && { sortOrder: data.sortOrder }), }, }); }), diff --git a/apps/api/src/frameworks/framework-versioning/manifest.types.ts b/apps/api/src/frameworks/framework-versioning/manifest.types.ts index f47ae009ac..e76a66e594 100644 --- a/apps/api/src/frameworks/framework-versioning/manifest.types.ts +++ b/apps/api/src/frameworks/framework-versioning/manifest.types.ts @@ -21,6 +21,7 @@ export interface ManifestRequirement { name: string; description: string | null; requirementFamily?: string | null; + sortOrder?: number | null; // FRAME-18: per-framework display order } export interface ManifestControl { diff --git a/apps/api/src/frameworks/frameworks.service.spec.ts b/apps/api/src/frameworks/frameworks.service.spec.ts index 81b4e28e69..dad4f7b141 100644 --- a/apps/api/src/frameworks/frameworks.service.spec.ts +++ b/apps/api/src/frameworks/frameworks.service.spec.ts @@ -30,6 +30,12 @@ jest.mock('@db', () => ({ evidenceSubmission: { findMany: jest.fn(), }, + frameworkEditorFramework: { + findMany: jest.fn(), + }, + customFramework: { + findMany: jest.fn(), + }, }, // The frameworks-timeline helper imports FindingType (a Prisma enum) at module // load. Stub it so the spec file can be evaluated without the real client. @@ -163,6 +169,40 @@ describe('FrameworksService', () => { }); }); + // Regression coverage for "GDPR framework showing as HIPAA": findAvailable + // feeds the setup screen, which auto-selects visibleFrameworks[0] when the + // user hasn't toggled a pill. Without a deterministic orderBy, Postgres + // returned platform frameworks in arbitrary order, so the silent default + // could land on the wrong framework (e.g. HIPAA when GDPR was expected). + describe('findAvailable', () => { + beforeEach(() => { + (mockDb.frameworkEditorFramework.findMany as jest.Mock).mockResolvedValue( + [], + ); + (mockDb.customFramework.findMany as jest.Mock).mockResolvedValue([]); + }); + + it('orders platform frameworks deterministically by name', async () => { + await service.findAvailable(); + + expect(mockDb.frameworkEditorFramework.findMany).toHaveBeenCalledWith({ + where: { visible: true }, + include: { requirements: true }, + orderBy: { name: 'asc' }, + }); + }); + + it('orders an org\'s custom frameworks deterministically by name', async () => { + await service.findAvailable('org_1'); + + expect(mockDb.customFramework.findMany).toHaveBeenCalledWith({ + where: { organizationId: 'org_1' }, + include: { requirements: true }, + orderBy: { name: 'asc' }, + }); + }); + }); + // Regression coverage for the cross-tenant leak that existed on this branch // before the split: previously both findOne and findRequirement read from // FrameworkEditorRequirement without filtering by organizationId, so an org's diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 2230f4a089..7ee1d65b7f 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -25,11 +25,31 @@ type RequirementDef = { identifier: string; description: string; requirementFamily?: string | null; + sortOrder?: number | null; frameworkId: string | null; customFrameworkId: string | null; kind: 'platform' | 'custom'; }; +// FRAME-18: numbered requirements first (ascending), unset rows (incl. per-instance +// custom requirements) last, then by identifier (the canonical-order key, e.g. +// CC6.1 / GV.OC-01) with name as a final tiebreak. Mirrors the app-side +// `compareRequirementsByOrder` so server and client agree on the order. +function compareRequirementDefs(a: RequirementDef, b: RequirementDef): number { + const ao = a.sortOrder ?? null; + const bo = b.sortOrder ?? null; + if (ao !== bo) { + if (ao === null) return 1; + if (bo === null) return -1; + return ao - bo; + } + const byIdentifier = (a.identifier ?? '').localeCompare(b.identifier ?? '', undefined, { + numeric: true, + }); + if (byIdentifier !== 0) return byIdentifier; + return a.name.localeCompare(b.name); +} + @Injectable() export class FrameworksService { private readonly logger = new Logger(FrameworksService.name); @@ -106,20 +126,23 @@ export class FrameworksService { identifier: r.identifier, description: r.description ?? '', requirementFamily: r.requirementFamily ?? null, + sortOrder: r.sortOrder ?? null, frameworkId: fi.frameworkId, customFrameworkId: null, kind: 'platform', }), ); - return [...platformDefs, ...customDefs].sort((a, b) => - a.name.localeCompare(b.name), - ); + return [...platformDefs, ...customDefs].sort(compareRequirementDefs); } } // Fallback: instances with no pinned version (shouldn't happen post-backfill). const rows = await db.frameworkEditorRequirement.findMany({ where: { frameworkId: fi.frameworkId }, - orderBy: { name: 'asc' }, + orderBy: [ + { sortOrder: { sort: 'asc', nulls: 'last' } }, + { identifier: 'asc' }, + { name: 'asc' }, + ], }); const platformDefs: RequirementDef[] = rows.map((r) => ({ id: r.id, @@ -127,13 +150,12 @@ export class FrameworksService { identifier: r.identifier, description: r.description, requirementFamily: r.requirementFamily ?? null, + sortOrder: r.sortOrder ?? null, frameworkId: r.frameworkId, customFrameworkId: null, kind: 'platform', })); - return [...platformDefs, ...customDefs].sort((a, b) => - a.name.localeCompare(b.name), - ); + return [...platformDefs, ...customDefs].sort(compareRequirementDefs); } return []; } @@ -403,11 +425,13 @@ export class FrameworksService { db.frameworkEditorFramework.findMany({ where: { visible: true }, include: { requirements: true }, + orderBy: { name: 'asc' }, }), organizationId ? db.customFramework.findMany({ where: { organizationId }, include: { requirements: true }, + orderBy: { name: 'asc' }, }) : Promise.resolve([]), ]); diff --git a/apps/api/src/people/utils/member-queries.spec.ts b/apps/api/src/people/utils/member-queries.spec.ts index 90f53ba7a3..ac804e8443 100644 --- a/apps/api/src/people/utils/member-queries.spec.ts +++ b/apps/api/src/people/utils/member-queries.spec.ts @@ -88,3 +88,40 @@ describe('MemberQueries.updateMember — background-check exemption fields', () expect(call.data).not.toHaveProperty('backgroundCheckExemptJustification'); }); }); + +describe('MemberQueries.updateMember — reactivation', () => { + beforeEach(() => { + jest.clearAllMocks(); + (mockedDb.member.update as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + }); + + // Regression for "Unable to reactivate user": a member deactivated via + // offboarding carries deactivated:true. The status dropdown reactivates by + // sending { isActive: true }; without also clearing deactivated the member + // stays hidden from the people list, so isActive alone is not enough. + it('clears deactivated when reactivating via isActive: true', async () => { + await MemberQueries.updateMember('mem_1', 'org_1', { isActive: true }); + + expect(mockedDb.member.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'mem_1', organizationId: 'org_1' }, + data: expect.objectContaining({ isActive: true, deactivated: false }), + }), + ); + }); + + it('does not touch deactivated when the patch omits isActive', async () => { + await MemberQueries.updateMember('mem_1', 'org_1', { jobTitle: 'Engineer' }); + + const call = (mockedDb.member.update as jest.Mock).mock.calls[0][0]; + expect(call.data).not.toHaveProperty('deactivated'); + }); + + it('does not reactivate when deactivating via isActive: false', async () => { + await MemberQueries.updateMember('mem_1', 'org_1', { isActive: false }); + + const call = (mockedDb.member.update as jest.Mock).mock.calls[0][0]; + expect(call.data.isActive).toBe(false); + expect(call.data).not.toHaveProperty('deactivated'); + }); +}); diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index d9f70cd356..abf56d24f9 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -169,6 +169,14 @@ export class MemberQueries { updatePayload.backgroundCheckExemptJustification = null; } + // Reactivation: the status dropdown sends { isActive: true } via PATCH. A + // member deactivated via offboarding (or the skip-offboarding path) carries + // deactivated:true, which hides them from the people list. Clear it so + // isActive and deactivated stay in sync and the member is fully restored. + if (updatePayload.isActive === true) { + updatePayload.deactivated = false; + } + const hasUserUpdates = name !== undefined || email !== undefined; const hasMemberUpdates = Object.keys(updatePayload).length > 0; diff --git a/apps/api/src/people/utils/member-validator.spec.ts b/apps/api/src/people/utils/member-validator.spec.ts new file mode 100644 index 0000000000..4fb06bf5d7 --- /dev/null +++ b/apps/api/src/people/utils/member-validator.spec.ts @@ -0,0 +1,81 @@ +import { NotFoundException } from '@nestjs/common'; +import { MemberValidator } from './member-validator'; + +jest.mock('@db', () => ({ + db: { + member: { + findFirst: jest.fn(), + }, + }, +})); + +import { db } from '@db'; + +const mockedDb = db as jest.Mocked; + +describe('MemberValidator.validateMemberExists — deactivated members', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Regression for "Unable to reactivate user": reactivation flows through the + // member-update path, which calls validateMemberExists first. A member + // deactivated via offboarding (deactivated:true) must still be found here, or + // the update 404s before it can clear the flag. + it('finds a deactivated member (does not filter deactivated:false)', async () => { + const deactivatedMember = { + id: 'mem_1', + userId: 'usr_1', + role: 'employee', + deactivated: true, + organizationId: 'org_1', + }; + + // Emulate Prisma: a query restricting deactivated:false cannot match a + // member whose deactivated is true. + (mockedDb.member.findFirst as jest.Mock).mockImplementation( + ({ where }: { where: Record }) => { + if ( + where.id !== deactivatedMember.id || + where.organizationId !== deactivatedMember.organizationId + ) { + return Promise.resolve(null); + } + if (where.deactivated === false && deactivatedMember.deactivated) { + return Promise.resolve(null); + } + return Promise.resolve({ + id: deactivatedMember.id, + userId: deactivatedMember.userId, + role: deactivatedMember.role, + }); + }, + ); + + await expect( + MemberValidator.validateMemberExists('mem_1', 'org_1'), + ).resolves.toEqual({ id: 'mem_1', userId: 'usr_1', role: 'employee' }); + }); + + it('throws NotFoundException when the member is not in the organization', async () => { + (mockedDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + MemberValidator.validateMemberExists('mem_x', 'org_1'), + ).rejects.toThrow(NotFoundException); + }); + + it('scopes the lookup to the organization', async () => { + (mockedDb.member.findFirst as jest.Mock).mockResolvedValue({ + id: 'mem_1', + userId: 'usr_1', + role: 'employee', + }); + + await MemberValidator.validateMemberExists('mem_1', 'org_1'); + + const call = (mockedDb.member.findFirst as jest.Mock).mock.calls[0][0]; + expect(call.where.id).toBe('mem_1'); + expect(call.where.organizationId).toBe('org_1'); + }); +}); diff --git a/apps/api/src/people/utils/member-validator.ts b/apps/api/src/people/utils/member-validator.ts index a412eb0b92..e8b43969f5 100644 --- a/apps/api/src/people/utils/member-validator.ts +++ b/apps/api/src/people/utils/member-validator.ts @@ -37,7 +37,12 @@ export class MemberValidator { } /** - * Validates that a member exists in an organization + * Validates that a member exists in an organization. + * + * Deactivated members are intentionally included. This check guards the + * member-update path, and reactivating a deactivated member (the status + * dropdown sends PATCH { isActive: true }) must be able to find them. + * Organization scoping is preserved. */ static async validateMemberExists( memberId: string, @@ -47,7 +52,6 @@ export class MemberValidator { where: { id: memberId, organizationId, - deactivated: false, }, select: { id: true, userId: true, role: true }, }); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.spec.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.spec.ts new file mode 100644 index 0000000000..d445fa8497 --- /dev/null +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.spec.ts @@ -0,0 +1,54 @@ +jest.mock('@db', () => ({ + db: { + browserAutomation: { findUnique: jest.fn(), update: jest.fn() }, + browserAutomationRun: { create: jest.fn() }, + task: { findUnique: jest.fn(), update: jest.fn() }, + organization: { findUnique: jest.fn() }, + member: { findMany: jest.fn() }, + }, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + tags: { add: jest.fn() }, + task: (config: unknown) => config, +})); + +jest.mock('../../email/trigger-email', () => ({ + triggerEmail: jest.fn(), +})); + +jest.mock('@trycompai/email', () => ({ + isUserUnsubscribed: jest.fn().mockResolvedValue(false), +})); + +import { shouldMarkTaskDoneAfterBrowserRun } from './run-browser-automation'; + +describe('shouldMarkTaskDoneAfterBrowserRun', () => { + it('allows screenshot-only automations to complete tasks', () => { + expect( + shouldMarkTaskDoneAfterBrowserRun({ + screenshotUrl: 'https://example.com/screenshot.jpg', + evaluationCriteria: null, + }), + ).toBe(true); + }); + + it('requires a passing evaluation when criteria are configured', () => { + expect( + shouldMarkTaskDoneAfterBrowserRun({ + screenshotUrl: 'https://example.com/screenshot.jpg', + evaluationCriteria: 'Branch protection is enabled', + evaluationStatus: 'pass', + }), + ).toBe(true); + + expect( + shouldMarkTaskDoneAfterBrowserRun({ + screenshotUrl: 'https://example.com/screenshot.jpg', + evaluationCriteria: 'Branch protection is enabled', + evaluationStatus: 'fail', + }), + ).toBe(false); + }); +}); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts index 26b54945a2..05dbf1d024 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts @@ -7,6 +7,25 @@ import { isUserUnsubscribed } from '@trycompai/email'; const browserbaseService = new BrowserbaseService(); +const browserAutomationConcurrencyLimit = (): number => { + const parsed = Number.parseInt( + process.env.BROWSER_AUTOMATION_GLOBAL_CONCURRENCY ?? '20', + 10, + ); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 20; +}; + +export function shouldMarkTaskDoneAfterBrowserRun(input: { + screenshotUrl?: string; + evaluationCriteria?: string | null; + evaluationStatus?: 'pass' | 'fail'; +}): boolean { + if (!input.screenshotUrl) return false; + const criteria = input.evaluationCriteria?.trim(); + if (!criteria) return true; + return input.evaluationStatus === 'pass'; +} + /** * Send email notifications for task status change */ @@ -171,7 +190,7 @@ export const runBrowserAutomation = task({ id: 'run-browser-automation', maxDuration: 1000 * 60 * 10, // 10 minutes per automation queue: { - concurrencyLimit: 80, + concurrencyLimit: browserAutomationConcurrencyLimit(), }, retry: { maxAttempts: 2, @@ -217,29 +236,6 @@ export const runBrowserAutomation = task({ }); const taskTitle = taskDetails?.title ?? 'Unknown Task'; - // Check if org has browser context - const context = await browserbaseService.getOrgContext(organizationId); - if (!context) { - logger.error(`No browser context for org ${organizationId}`); - - // Create a failed run record - await db.browserAutomationRun.create({ - data: { - automationId, - status: 'failed', - startedAt: new Date(), - completedAt: new Date(), - error: 'No browser context. Please connect your browser in settings.', - }, - }); - - return { - success: false, - error: 'No browser context', - needsReauth: true, - }; - } - // Run the automation const result = await browserbaseService.runBrowserAutomation( automationId, @@ -252,8 +248,13 @@ export const runBrowserAutomation = task({ screenshotUrl: result.screenshotUrl ? 'captured' : 'none', }); - // Update task status to done if screenshot was captured - if (result.screenshotUrl) { + if ( + shouldMarkTaskDoneAfterBrowserRun({ + screenshotUrl: result.screenshotUrl, + evaluationCriteria: automation.evaluationCriteria, + evaluationStatus: result.evaluationStatus, + }) + ) { const currentTask = await db.task.findUnique({ where: { id: taskId }, select: { status: true, frequency: true }, @@ -349,6 +350,7 @@ export const runBrowserAutomation = task({ screenshotUrl: result.screenshotUrl, error: result.error, needsReauth: result.needsReauth, + failureCode: result.failureCode, }; }, }); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts index 2ae7fb5a7d..6e09e8e537 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts @@ -1,5 +1,8 @@ import { TaskFrequency } from '@trycompai/db'; -import { filterDueAutomations } from './run-browser-automations-schedule'; +import { + filterDueAutomations, + limitAutomationBatch, +} from './run-browser-automations-schedule'; // Mock @db at the module boundary so importing the orchestrator does not try // to connect to Postgres. We never call the scheduled `run` function itself @@ -116,3 +119,98 @@ describe('filterDueAutomations (browser automation orchestrator)', () => { expect(due).toEqual([]); }); }); + +describe('limitAutomationBatch', () => { + it('limits due automations by organization and hostname', () => { + const automations = [ + { + id: 'ba_1', + targetUrl: 'https://github.com/a', + task: { organizationId: 'org_1' }, + }, + { + id: 'ba_2', + targetUrl: 'https://github.com/b', + task: { organizationId: 'org_1' }, + }, + { + id: 'ba_3', + targetUrl: 'https://gitlab.com/a', + task: { organizationId: 'org_1' }, + }, + { + id: 'ba_4', + targetUrl: 'https://github.com/c', + task: { organizationId: 'org_2' }, + }, + ]; + + const limited = limitAutomationBatch({ + automations, + maxPerOrg: 2, + maxPerHostname: 2, + }); + + expect(limited.map((automation) => automation.id)).toEqual([ + 'ba_1', + 'ba_2', + ]); + }); + + it('prioritizes never-run and oldest automations before applying caps', () => { + const automations = [ + { + id: 'ba_newer', + lastRunAt: atUtc('2026-04-20'), + targetUrl: 'https://github.com/newer', + task: { organizationId: 'org_1' }, + }, + { + id: 'ba_never', + lastRunAt: null, + targetUrl: 'https://github.com/never', + task: { organizationId: 'org_1' }, + }, + { + id: 'ba_older', + lastRunAt: atUtc('2026-04-01'), + targetUrl: 'https://gitlab.com/older', + task: { organizationId: 'org_1' }, + }, + ]; + + const limited = limitAutomationBatch({ + automations, + maxPerOrg: 2, + maxPerHostname: 2, + }); + + expect(limited.map((automation) => automation.id)).toEqual([ + 'ba_never', + 'ba_older', + ]); + }); + + it('skips malformed target URLs without dropping valid automations', () => { + const automations = [ + { + id: 'ba_bad', + targetUrl: 'not-a-url', + task: { organizationId: 'org_1' }, + }, + { + id: 'ba_good', + targetUrl: 'https://github.com/a', + task: { organizationId: 'org_1' }, + }, + ]; + + const limited = limitAutomationBatch({ + automations, + maxPerOrg: 2, + maxPerHostname: 2, + }); + + expect(limited.map((automation) => automation.id)).toEqual(['ba_good']); + }); +}); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts index dedd4b5660..d3aceb930b 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts @@ -2,6 +2,7 @@ import { db, TaskFrequency } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; import { runBrowserAutomation } from './run-browser-automation'; import { isDueToday } from '../shared/is-due-today'; +import { normalizeHostnameFromUrl } from '../../browserbase/browserbase-url'; /** * Pure helper extracted for unit testing. Filters a list of candidate @@ -25,6 +26,66 @@ export function filterDueAutomations< ); } +const parsePositiveInt = ( + value: string | undefined, + fallback: number, +): number => { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +export function limitAutomationBatch< + T extends { + id?: string; + lastRunAt?: Date | null; + targetUrl: string; + task: { organizationId: string }; + }, +>({ + automations, + maxPerOrg, + maxPerHostname, +}: { + automations: T[]; + maxPerOrg: number; + maxPerHostname: number; +}): T[] { + const orgCounts = new Map(); + const hostnameCounts = new Map(); + const selected: T[] = []; + const sortedAutomations = [...automations].sort( + (a, b) => (a.lastRunAt?.getTime() ?? 0) - (b.lastRunAt?.getTime() ?? 0), + ); + + for (const automation of sortedAutomations) { + const organizationId = automation.task.organizationId; + let hostname: string; + try { + hostname = normalizeHostnameFromUrl(automation.targetUrl); + } catch (error) { + logger.warn('Skipping browser automation with invalid target URL', { + automationId: automation.id, + targetUrl: automation.targetUrl, + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + const orgCount = orgCounts.get(organizationId) ?? 0; + const hostnameCount = hostnameCounts.get(hostname) ?? 0; + + if (orgCount >= maxPerOrg || hostnameCount >= maxPerHostname) { + continue; + } + + orgCounts.set(organizationId, orgCount + 1); + hostnameCounts.set(hostname, hostnameCount + 1); + selected.push(automation); + } + + return selected; +} + /** * Daily scheduled task (orchestrator) that finds all enabled browser automations * and triggers individual runs for each. @@ -48,6 +109,7 @@ export const browserAutomationsSchedule = schedules.task({ id: true, name: true, taskId: true, + targetUrl: true, scheduleFrequency: true, lastRunAt: true, task: { @@ -89,8 +151,26 @@ export const browserAutomationsSchedule = schedules.task({ return { success: true, automationsTriggered: 0 }; } + const limitedAutomations = limitAutomationBatch({ + automations, + maxPerOrg: parsePositiveInt( + process.env.BROWSER_AUTOMATION_ORG_CONCURRENCY, + 5, + ), + maxPerHostname: parsePositiveInt( + process.env.BROWSER_AUTOMATION_HOST_CONCURRENCY, + 3, + ), + }); + + if (limitedAutomations.length < automations.length) { + logger.info( + `Deferred ${automations.length - limitedAutomations.length} automation(s) due to org/domain concurrency limits`, + ); + } + // Build payloads for batch triggering - const triggerPayloads = automations.map((automation) => ({ + const triggerPayloads = limitedAutomations.map((automation) => ({ payload: { automationId: automation.id, automationName: automation.name, diff --git a/apps/api/src/trigger/policies/update-policy.ts b/apps/api/src/trigger/policies/update-policy.ts index 93caa20eb7..284b283c33 100644 --- a/apps/api/src/trigger/policies/update-policy.ts +++ b/apps/api/src/trigger/policies/update-policy.ts @@ -26,6 +26,9 @@ export const updatePolicy = schemaTask({ description: z.string(), visible: z.boolean(), organizationId: z.string().nullable().default(null), + // FRAME-20: frameworks now carry a family pointer; keep this payload + // schema in step with the FrameworkEditorFramework shape it's typed as. + familyId: z.string().nullable().default(null), createdAt: z.date(), updatedAt: z.date(), }), diff --git a/apps/api/src/trust-portal/trust-access.service.spec.ts b/apps/api/src/trust-portal/trust-access.service.spec.ts index f5bab9ac1a..5443807487 100644 --- a/apps/api/src/trust-portal/trust-access.service.spec.ts +++ b/apps/api/src/trust-portal/trust-access.service.spec.ts @@ -1,6 +1,7 @@ import { GetObjectCommand } from '@aws-sdk/client-s3'; import { db } from '@db'; import { getSignedUrl } from '../app/s3'; +import { CreateAccessRequestDto } from './dto/trust-access.dto'; import { TrustAccessService } from './trust-access.service'; jest.mock('@db', () => ({ @@ -445,3 +446,51 @@ describe('TrustAccessService signNda NDA copy', () => { ); }); }); + +describe('TrustAccessService access request notification', () => { + const emailService = { + sendAccessRequestNotification: jest.fn(), + }; + const service = new TrustAccessService( + ...([{}, emailService, {}, {}, {}] as unknown as ConstructorParameters< + typeof TrustAccessService + >), + ); + + const ORIGINAL_BETTER_AUTH_URL = process.env.BETTER_AUTH_URL; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.BETTER_AUTH_URL = 'https://app.trycomp.ai'; + }); + + afterAll(() => { + process.env.BETTER_AUTH_URL = ORIGINAL_BETTER_AUTH_URL; + }); + + it('points the review button at the access requests page, not the trust overview', async () => { + // contactEmail present -> single recipient, no member fallback lookup. + mockDb.trust.findUnique.mockResolvedValue({ contactEmail: 'owner@acme.com' }); + + const dto: CreateAccessRequestDto = { + name: 'Jane Doe', + email: 'jane@example.com', + }; + + await service['sendAccessRequestNotificationToOrg']( + 'org_123', + 'tar_456', + 'Acme Inc', + dto, + ); + + expect(emailService.sendAccessRequestNotification).toHaveBeenCalledTimes(1); + // Must deep-link to the pending requests list, NOT /org_123/trust (the + // trust portal settings/overview page). + expect(emailService.sendAccessRequestNotification).toHaveBeenCalledWith( + expect.objectContaining({ + reviewUrl: 'https://app.trycomp.ai/org_123/trust/access-requests', + }), + ); + }); +}); diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 6af1bc5c04..899aad5c83 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -430,8 +430,9 @@ export class TrustAccessService { return; } - // Construct review URL - const reviewUrl = `${process.env.BETTER_AUTH_URL}/${organizationId}/trust`; + // Construct review URL pointing at the pending access requests list, not + // the trust portal settings/overview page. + const reviewUrl = `${process.env.BETTER_AUTH_URL}/${organizationId}/trust/access-requests`; // Send notification to all recipients const emailPromises = notificationEmails.map((email) => diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.test.tsx new file mode 100644 index 0000000000..8499955e51 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.test.tsx @@ -0,0 +1,165 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ChangeEventHandler, ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Capture client-side navigation so we can assert when the wizard leaves the page. +const mockPush = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, refresh: vi.fn() }), +})); + +// next/link is only referenced by the pre-fix Cancel control; keep it inert. +vi.mock('next/link', () => ({ + default: ({ children }: { children?: ReactNode }) => {children}, +})); + +vi.mock('@trycompai/company', () => ({ meetingFields: () => [] })); +vi.mock('@/components/file-uploader', () => ({ FileUploader: () => null })); +vi.mock('@/lib/api-client', () => ({ api: { post: vi.fn() } })); +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +// Minimal evidence form: one text field on step 1 is enough to exercise the +// unsaved-changes guard without depending on real form catalog content. +vi.mock('@/app/(app)/[orgId]/documents/forms', async () => { + const { z } = await import('zod'); + return { + evidenceFormDefinitions: { + 'tabletop-exercise': { + title: 'Tabletop Exercise', + submissionDateMode: 'auto', + fields: [{ key: 'summary', label: 'Summary', type: 'text' }], + }, + }, + evidenceFormSubmissionSchemaMap: { + 'tabletop-exercise': z.object({ summary: z.string().min(1) }), + }, + meetingMinutesPlaceholders: {}, + meetingSubTypes: [], + }; +}); + +// Faithful-enough design system: AlertDialog honours `open`, inputs/buttons +// forward the handlers the wizard relies on. +vi.mock('@trycompai/design-system', () => ({ + Alert: ({ title, description }: { title?: ReactNode; description?: ReactNode }) => ( +
+ {title} + {description} +
+ ), + Button: ({ + children, + onClick, + type, + disabled, + }: { + children?: ReactNode; + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }) => ( + + ), + Field: ({ children }: { children?: ReactNode }) =>
{children}
, + FieldError: () => null, + FieldGroup: ({ children }: { children?: ReactNode }) =>
{children}
, + FieldLabel: ({ children, htmlFor }: { children?: ReactNode; htmlFor?: string }) => ( + + ), + Input: ({ + id, + type, + value, + onChange, + placeholder, + }: { + id?: string; + type?: string; + value?: string; + onChange?: ChangeEventHandler; + placeholder?: string; + }) => ( + + ), + Section: ({ children }: { children?: ReactNode }) =>
{children}
, + Select: ({ children }: { children?: ReactNode }) =>
{children}
, + SelectContent: ({ children }: { children?: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children?: ReactNode }) =>
{children}
, + SelectTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + SelectValue: ({ children }: { children?: ReactNode }) => {children}, + Text: ({ children }: { children?: ReactNode }) => {children}, + Textarea: ({ + id, + value, + onChange, + placeholder, + }: { + id?: string; + value?: string; + onChange?: ChangeEventHandler; + placeholder?: string; + }) =>