Skip to content

Commit 98e863a

Browse files
authored
Merge branch 'main' into tofik/cs-789-bug-add-button-on-aws
2 parents 8cf2d3c + 7345e3c commit 98e863a

15 files changed

Lines changed: 1476 additions & 268 deletions

‎apps/api/src/browserbase/browser-auth-profiles.controller.ts‎

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { OrganizationId } from '../auth/auth-context.decorator';
2323
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
2424
import { PermissionGuard } from '../auth/permission.guard';
2525
import { RequirePermission } from '../auth/require-permission.decorator';
26-
import { BrowserCredentialStorageService } from './browser-credential-storage.service';
2726
import {
2827
BrowserMfaInstructionsService,
2928
type MfaInstructions,
@@ -35,7 +34,6 @@ import {
3534
ResolveAuthProfileDto,
3635
ResolveAuthProfileResponseDto,
3736
SessionResponseDto,
38-
SetAuthProfileTotpDto,
3937
SignInAuthProfileDto,
4038
SignInAuthProfileResponseDto,
4139
StoreAuthProfileCredentialsDto,
@@ -52,7 +50,6 @@ export class BrowserAuthProfilesController {
5250
constructor(
5351
private readonly browserbaseService: BrowserbaseService,
5452
private readonly mfaInstructionsService: BrowserMfaInstructionsService,
55-
private readonly credentialStorageService: BrowserCredentialStorageService,
5653
) {}
5754

5855
@Get('mfa-instructions')
@@ -185,66 +182,6 @@ export class BrowserAuthProfilesController {
185182
})) as BrowserAuthProfileResponseDto;
186183
}
187184

188-
@Get('profiles/:profileId/totp')
189-
@RequirePermission('integration', 'read')
190-
@ApiOperation({
191-
summary: 'Get automatic-2FA status for a connection',
192-
description:
193-
"Reports whether an authenticator setup key (TOTP seed) is stored for this connection, read live from the vault, so scheduled sign-ins can generate 2FA codes unattended.",
194-
})
195-
@ApiParam({ name: 'profileId', description: 'Browser auth profile ID' })
196-
@ApiResponse({ status: 200 })
197-
async getProfileTotp(
198-
@OrganizationId() organizationId: string,
199-
@Param('profileId') profileId: string,
200-
): Promise<{ configured: boolean }> {
201-
return this.credentialStorageService.getProfileTotpStatus({
202-
organizationId,
203-
profileId,
204-
});
205-
}
206-
207-
@Post('profiles/:profileId/totp')
208-
@RequirePermission('integration', 'update')
209-
@ApiOperation({
210-
summary: 'Store an authenticator setup key for a connection',
211-
description:
212-
"Attach or replace the authenticator setup key (TOTP seed) on this connection's stored login, enabling unattended 2FA. Does not require re-entering the username or password.",
213-
})
214-
@ApiParam({ name: 'profileId', description: 'Browser auth profile ID' })
215-
@ApiBody({ type: SetAuthProfileTotpDto })
216-
@ApiResponse({ status: 201 })
217-
async setProfileTotp(
218-
@OrganizationId() organizationId: string,
219-
@Param('profileId') profileId: string,
220-
@Body() dto: SetAuthProfileTotpDto,
221-
): Promise<{ configured: boolean }> {
222-
return this.credentialStorageService.setProfileTotp({
223-
organizationId,
224-
profileId,
225-
totpSeed: dto.totpSeed,
226-
});
227-
}
228-
229-
@Delete('profiles/:profileId/totp')
230-
@RequirePermission('integration', 'update')
231-
@ApiOperation({
232-
summary: 'Turn off automatic 2FA for a connection',
233-
description:
234-
'Remove the stored authenticator setup key. Scheduled runs pause if the vendor then asks for a code.',
235-
})
236-
@ApiParam({ name: 'profileId', description: 'Browser auth profile ID' })
237-
@ApiResponse({ status: 200 })
238-
async clearProfileTotp(
239-
@OrganizationId() organizationId: string,
240-
@Param('profileId') profileId: string,
241-
): Promise<{ configured: boolean }> {
242-
return this.credentialStorageService.clearProfileTotp({
243-
organizationId,
244-
profileId,
245-
});
246-
}
247-
248185
@Post('profiles/:profileId/sign-in')
249186
@RequirePermission('integration', 'update')
250187
@ApiOperation({
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Reflector } from '@nestjs/core';
2+
import type { BrowserCredentialStorageService } from './browser-credential-storage.service';
3+
import { BrowserAuthTotpController } from './browser-auth-totp.controller';
4+
5+
// The controller imports the auth guards, which pull in the real Prisma client
6+
// and better-auth (ESM) at module load. Stub those so importing the controller
7+
// doesn't open a DB connection or fail to parse — the guards aren't executed here
8+
// (we test the controller methods + their RBAC metadata directly).
9+
jest.mock('@db', () => ({
10+
db: {},
11+
// The DTO module (transitively imported) decorates fields with
12+
// @IsEnum(TaskFrequency), so the enum must exist at module-eval time.
13+
TaskFrequency: {
14+
daily: 'daily',
15+
weekly: 'weekly',
16+
monthly: 'monthly',
17+
quarterly: 'quarterly',
18+
yearly: 'yearly',
19+
},
20+
}));
21+
jest.mock('../auth/auth.server', () => ({
22+
auth: { api: { getSession: jest.fn() } },
23+
}));
24+
jest.mock('@trycompai/auth', () => ({
25+
statement: {},
26+
BUILT_IN_ROLE_PERMISSIONS: {},
27+
}));
28+
29+
// Mirrors PERMISSIONS_KEY in ../auth/permission.guard — imported by value rather
30+
// than by reference so the test doesn't pull the guard's better-auth (ESM) chain
31+
// into Jest. Keep in sync with the guard.
32+
const PERMISSIONS_KEY = 'required_permissions';
33+
34+
describe('BrowserAuthTotpController', () => {
35+
const service = {
36+
getOrgTotpStatuses: jest.fn(),
37+
getProfileTotpStatus: jest.fn(),
38+
setProfileTotp: jest.fn(),
39+
clearProfileTotp: jest.fn(),
40+
};
41+
let controller: BrowserAuthTotpController;
42+
43+
beforeEach(() => {
44+
jest.clearAllMocks();
45+
controller = new BrowserAuthTotpController(
46+
service as unknown as BrowserCredentialStorageService,
47+
);
48+
});
49+
50+
describe('delegation (organization + profile scoping reaches storage)', () => {
51+
it('getOrgTotpStatuses passes the org id and wraps the map', async () => {
52+
service.getOrgTotpStatuses.mockResolvedValue({ bap_1: true });
53+
54+
const res = await controller.getOrgTotpStatuses('org_1');
55+
56+
expect(service.getOrgTotpStatuses).toHaveBeenCalledWith('org_1');
57+
expect(res).toEqual({ statuses: { bap_1: true } });
58+
});
59+
60+
it('getProfileTotp passes the org + profile ids', async () => {
61+
service.getProfileTotpStatus.mockResolvedValue({ configured: true });
62+
63+
const res = await controller.getProfileTotp('org_1', 'bap_1');
64+
65+
expect(service.getProfileTotpStatus).toHaveBeenCalledWith({
66+
organizationId: 'org_1',
67+
profileId: 'bap_1',
68+
});
69+
expect(res).toEqual({ configured: true });
70+
});
71+
72+
it('setProfileTotp forwards the seed with org + profile ids', async () => {
73+
service.setProfileTotp.mockResolvedValue({ configured: true });
74+
75+
await controller.setProfileTotp('org_1', 'bap_1', {
76+
totpSeed: 'JBSWY3DPEHPK3PXP',
77+
});
78+
79+
expect(service.setProfileTotp).toHaveBeenCalledWith({
80+
organizationId: 'org_1',
81+
profileId: 'bap_1',
82+
totpSeed: 'JBSWY3DPEHPK3PXP',
83+
});
84+
});
85+
86+
it('clearProfileTotp passes the org + profile ids', async () => {
87+
service.clearProfileTotp.mockResolvedValue({ configured: false });
88+
89+
await controller.clearProfileTotp('org_1', 'bap_1');
90+
91+
expect(service.clearProfileTotp).toHaveBeenCalledWith({
92+
organizationId: 'org_1',
93+
profileId: 'bap_1',
94+
});
95+
});
96+
});
97+
98+
describe('RBAC — reads require integration:read, writes require integration:update', () => {
99+
const reflector = new Reflector();
100+
const permsOf = (method: object) =>
101+
reflector.get(PERMISSIONS_KEY, method as never);
102+
103+
it('gates read routes with integration:read', () => {
104+
expect(permsOf(BrowserAuthTotpController.prototype.getOrgTotpStatuses)).toEqual(
105+
[{ resource: 'integration', actions: ['read'] }],
106+
);
107+
expect(permsOf(BrowserAuthTotpController.prototype.getProfileTotp)).toEqual([
108+
{ resource: 'integration', actions: ['read'] },
109+
]);
110+
});
111+
112+
it('gates write routes with integration:update', () => {
113+
expect(permsOf(BrowserAuthTotpController.prototype.setProfileTotp)).toEqual([
114+
{ resource: 'integration', actions: ['update'] },
115+
]);
116+
expect(permsOf(BrowserAuthTotpController.prototype.clearProfileTotp)).toEqual(
117+
[{ resource: 'integration', actions: ['update'] }],
118+
);
119+
});
120+
});
121+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
Param,
7+
Post,
8+
UseGuards,
9+
} from '@nestjs/common';
10+
import {
11+
ApiBody,
12+
ApiOperation,
13+
ApiParam,
14+
ApiResponse,
15+
ApiSecurity,
16+
ApiTags,
17+
} from '@nestjs/swagger';
18+
import { OrganizationId } from '../auth/auth-context.decorator';
19+
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
20+
import { PermissionGuard } from '../auth/permission.guard';
21+
import { RequirePermission } from '../auth/require-permission.decorator';
22+
import { BrowserCredentialStorageService } from './browser-credential-storage.service';
23+
import { SetAuthProfileTotpDto } from './dto/browserbase.dto';
24+
25+
/**
26+
* Automatic-2FA (TOTP) management for browser connections — kept in its own
27+
* controller so the main profiles controller stays within the file-size limit and
28+
* the 2FA routes are grouped. All routes share the `browserbase` v1 path prefix.
29+
*/
30+
@ApiTags('Browserbase')
31+
@Controller({ path: 'browserbase', version: '1' })
32+
@UseGuards(HybridAuthGuard, PermissionGuard)
33+
@ApiSecurity('apikey')
34+
export class BrowserAuthTotpController {
35+
constructor(
36+
private readonly credentialStorageService: BrowserCredentialStorageService,
37+
) {}
38+
39+
// Declared before the `profiles/:profileId/...` routes so its static path is
40+
// never captured as a profileId.
41+
@Get('profiles/totp-statuses')
42+
@RequirePermission('integration', 'read')
43+
@ApiOperation({
44+
summary: 'Automatic-2FA status for all password connections',
45+
description:
46+
'Reports, per connection, whether an authenticator setup key is stored — so the connections list can show which sign-ins keep running unattended and which may pause. Read live from the vault in one round-trip.',
47+
})
48+
@ApiResponse({ status: 200 })
49+
async getOrgTotpStatuses(
50+
@OrganizationId() organizationId: string,
51+
): Promise<{ statuses: Record<string, boolean> }> {
52+
const statuses =
53+
await this.credentialStorageService.getOrgTotpStatuses(organizationId);
54+
return { statuses };
55+
}
56+
57+
@Get('profiles/:profileId/totp')
58+
@RequirePermission('integration', 'read')
59+
@ApiOperation({
60+
summary: 'Get automatic-2FA status for a connection',
61+
description:
62+
'Reports whether an authenticator setup key (TOTP seed) is stored for this connection, read live from the vault, so scheduled sign-ins can generate 2FA codes unattended.',
63+
})
64+
@ApiParam({ name: 'profileId', description: 'Browser auth profile ID' })
65+
@ApiResponse({ status: 200 })
66+
async getProfileTotp(
67+
@OrganizationId() organizationId: string,
68+
@Param('profileId') profileId: string,
69+
): Promise<{ configured: boolean }> {
70+
return this.credentialStorageService.getProfileTotpStatus({
71+
organizationId,
72+
profileId,
73+
});
74+
}
75+
76+
@Post('profiles/:profileId/totp')
77+
@RequirePermission('integration', 'update')
78+
@ApiOperation({
79+
summary: 'Store an authenticator setup key for a connection',
80+
description:
81+
"Attach or replace the authenticator setup key (TOTP seed) on this connection's stored login, enabling unattended 2FA. Does not require re-entering the username or password.",
82+
})
83+
@ApiParam({ name: 'profileId', description: 'Browser auth profile ID' })
84+
@ApiBody({ type: SetAuthProfileTotpDto })
85+
@ApiResponse({ status: 201 })
86+
async setProfileTotp(
87+
@OrganizationId() organizationId: string,
88+
@Param('profileId') profileId: string,
89+
@Body() dto: SetAuthProfileTotpDto,
90+
): Promise<{ configured: boolean }> {
91+
return this.credentialStorageService.setProfileTotp({
92+
organizationId,
93+
profileId,
94+
totpSeed: dto.totpSeed,
95+
});
96+
}
97+
98+
@Delete('profiles/:profileId/totp')
99+
@RequirePermission('integration', 'update')
100+
@ApiOperation({
101+
summary: 'Turn off automatic 2FA for a connection',
102+
description:
103+
'Remove the stored authenticator setup key. Scheduled runs pause if the vendor then asks for a code.',
104+
})
105+
@ApiParam({ name: 'profileId', description: 'Browser auth profile ID' })
106+
@ApiResponse({ status: 200 })
107+
async clearProfileTotp(
108+
@OrganizationId() organizationId: string,
109+
@Param('profileId') profileId: string,
110+
): Promise<{ configured: boolean }> {
111+
return this.credentialStorageService.clearProfileTotp({
112+
organizationId,
113+
profileId,
114+
});
115+
}
116+
}

0 commit comments

Comments
 (0)