diff --git a/apps/api/src/device-agent/device-agent.controller.spec.ts b/apps/api/src/device-agent/device-agent.controller.spec.ts index e7d70e1bc6..624e1f04b8 100644 --- a/apps/api/src/device-agent/device-agent.controller.spec.ts +++ b/apps/api/src/device-agent/device-agent.controller.spec.ts @@ -8,6 +8,8 @@ import { PermissionGuard } from '../auth/permission.guard'; import type { AuthContext as AuthContextType } from '../auth/types'; import { Readable } from 'stream'; +jest.mock('@db', () => ({ db: {} })); + jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); @@ -26,6 +28,8 @@ describe('DeviceAgentController', () => { const mockService = { downloadMacAgent: jest.fn(), downloadWindowsAgent: jest.fn(), + getUpdateFile: jest.fn(), + headUpdateFile: jest.fn(), }; const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; @@ -42,6 +46,7 @@ describe('DeviceAgentController', () => { const mockRes = { set: jest.fn(), + redirect: jest.fn(), }; beforeEach(async () => { @@ -155,4 +160,97 @@ describe('DeviceAgentController', () => { ).rejects.toThrow('Agent not found'); }); }); + + describe('getUpdateFile', () => { + it('streams a yml manifest with cache headers', async () => { + const mockStream = Readable.from(Buffer.from('version: 1.0.5')); + mockService.getUpdateFile.mockResolvedValue({ + kind: 'stream', + stream: mockStream, + contentType: 'text/yaml', + contentLength: 14, + }); + + const result = await controller.getUpdateFile( + 'latest-mac.yml', + mockRes as never, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Type': 'text/yaml', + 'Cache-Control': 'public, max-age=300', + 'Content-Length': '14', + }), + ); + expect(mockRes.redirect).not.toHaveBeenCalled(); + }); + + it('issues a 302 redirect to the presigned URL for binaries', async () => { + mockService.getUpdateFile.mockResolvedValue({ + kind: 'redirect', + url: 'https://s3.example.com/signed-zip', + }); + + const result = await controller.getUpdateFile( + 'CompAI-Device-Agent-1.0.5-arm64.zip', + mockRes as never, + ); + + expect(mockRes.redirect).toHaveBeenCalledWith( + 302, + 'https://s3.example.com/signed-zip', + ); + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Cache-Control': 'no-store', + }), + ); + expect(result).toBeUndefined(); + }); + }); + + describe('headUpdateFile', () => { + it('returns metadata headers for a yml manifest', async () => { + mockService.headUpdateFile.mockResolvedValue({ + kind: 'stream', + contentType: 'text/yaml', + contentLength: 859, + }); + + const result = await controller.headUpdateFile( + 'latest-mac.yml', + mockRes as never, + ); + + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Type': 'text/yaml', + 'Cache-Control': 'public, max-age=300', + 'Content-Length': '859', + }), + ); + expect(result).toBe(''); + expect(mockRes.redirect).not.toHaveBeenCalled(); + }); + + it('issues a 302 redirect for HEAD on binaries', async () => { + mockService.headUpdateFile.mockResolvedValue({ + kind: 'redirect', + url: 'https://s3.example.com/signed-head', + }); + + const result = await controller.headUpdateFile( + 'CompAI-Device-Agent-1.0.5-arm64.zip', + mockRes as never, + ); + + expect(mockRes.redirect).toHaveBeenCalledWith( + 302, + 'https://s3.example.com/signed-head', + ); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/apps/api/src/device-agent/device-agent.controller.ts b/apps/api/src/device-agent/device-agent.controller.ts index e0975296f7..d719e3de3b 100644 --- a/apps/api/src/device-agent/device-agent.controller.ts +++ b/apps/api/src/device-agent/device-agent.controller.ts @@ -70,6 +70,12 @@ export class DeviceAgentController { ) { const result = await this.deviceAgentService.getUpdateFile({ filename }); + if (result.kind === 'redirect') { + res.set({ 'Cache-Control': 'no-store' }); + res.redirect(302, result.url); + return; + } + res.set({ 'Content-Type': result.contentType, 'Cache-Control': 'public, max-age=300', @@ -90,6 +96,12 @@ export class DeviceAgentController { ) { const result = await this.deviceAgentService.headUpdateFile({ filename }); + if (result.kind === 'redirect') { + res.set({ 'Cache-Control': 'no-store' }); + res.redirect(302, result.url); + return; + } + res.set({ 'Content-Type': result.contentType, 'Cache-Control': 'public, max-age=300', diff --git a/apps/api/src/device-agent/device-agent.service.spec.ts b/apps/api/src/device-agent/device-agent.service.spec.ts index 19df4eba1e..2450da1190 100644 --- a/apps/api/src/device-agent/device-agent.service.spec.ts +++ b/apps/api/src/device-agent/device-agent.service.spec.ts @@ -5,10 +5,28 @@ import { import { Readable } from 'stream'; const mockSend = jest.fn(); +const mockGetSignedUrl = jest.fn(); + +class MockGetObjectCommand { + constructor(public readonly input: unknown) { + Object.assign(this, input as object); + } +} + +class MockHeadObjectCommand { + constructor(public readonly input: unknown) { + Object.assign(this, input as object); + } +} jest.mock('@aws-sdk/client-s3', () => ({ S3Client: jest.fn().mockImplementation(() => ({ send: mockSend })), - GetObjectCommand: jest.fn().mockImplementation((input) => input), + GetObjectCommand: MockGetObjectCommand, + HeadObjectCommand: MockHeadObjectCommand, +})); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: (...args: unknown[]) => mockGetSignedUrl(...args), })); import { DeviceAgentService } from './device-agent.service'; @@ -92,6 +110,123 @@ describe('DeviceAgentService', () => { }); }); + describe('getUpdateFile', () => { + it('streams .yml manifests directly from S3', async () => { + const mockStream = new Readable({ read() {} }); + mockSend.mockResolvedValue({ + Body: mockStream, + ContentLength: 859, + }); + + const result = await service.getUpdateFile({ filename: 'latest-mac.yml' }); + + expect(result).toEqual({ + kind: 'stream', + stream: mockStream, + contentType: 'text/yaml', + contentLength: 859, + }); + expect(mockGetSignedUrl).not.toHaveBeenCalled(); + }); + + it('redirects binary downloads to a presigned S3 URL signed for GET', async () => { + mockGetSignedUrl.mockResolvedValue('https://s3.example.com/signed-zip-url'); + + const result = await service.getUpdateFile({ + filename: 'CompAI-Device-Agent-1.0.5-arm64.zip', + }); + + expect(result).toEqual({ + kind: 'redirect', + url: 'https://s3.example.com/signed-zip-url', + }); + expect(mockSend).not.toHaveBeenCalled(); + expect(mockGetSignedUrl).toHaveBeenCalledTimes(1); + const [, command] = mockGetSignedUrl.mock.calls[0]; + expect(command).toBeInstanceOf(MockGetObjectCommand); + expect(command).toMatchObject({ + Bucket: 'test-bucket', + Key: 'device-agent/production/updates/CompAI-Device-Agent-1.0.5-arm64.zip', + }); + }); + + it.each([ + 'CompAI-Device-Agent-1.0.5-arm64.zip', + 'CompAI-Device-Agent-1.0.5-setup.exe', + 'CompAI-Device-Agent-1.0.5-arm64.dmg', + 'CompAI-Device-Agent-1.0.5-x86_64.AppImage', + 'CompAI-Device-Agent-1.0.5-arm64.zip.blockmap', + ])('redirects binary file %s', async (filename) => { + mockGetSignedUrl.mockResolvedValue('https://s3.example.com/signed'); + + const result = await service.getUpdateFile({ filename }); + + expect(result).toEqual({ + kind: 'redirect', + url: 'https://s3.example.com/signed', + }); + }); + + it('throws NotFoundException for invalid filenames', async () => { + await expect( + service.getUpdateFile({ filename: '../etc/passwd' }), + ).rejects.toThrow(NotFoundException); + await expect( + service.getUpdateFile({ filename: 'foo.txt' }), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when S3 returns NoSuchKey for a yml manifest', async () => { + const error = new Error('Not found'); + error.name = 'NoSuchKey'; + mockSend.mockRejectedValue(error); + + await expect( + service.getUpdateFile({ filename: 'latest-mac.yml' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('headUpdateFile', () => { + it('returns metadata for .yml manifests', async () => { + mockSend.mockResolvedValue({ ContentLength: 859 }); + + const result = await service.headUpdateFile({ + filename: 'latest-mac.yml', + }); + + expect(result).toEqual({ + kind: 'stream', + contentType: 'text/yaml', + contentLength: 859, + }); + expect(mockGetSignedUrl).not.toHaveBeenCalled(); + }); + + it('redirects binary HEAD requests to a URL signed with HeadObjectCommand', async () => { + mockGetSignedUrl.mockResolvedValue('https://s3.example.com/signed-head'); + + const result = await service.headUpdateFile({ + filename: 'CompAI-Device-Agent-1.0.5-arm64.zip', + }); + + expect(result).toEqual({ + kind: 'redirect', + url: 'https://s3.example.com/signed-head', + }); + expect(mockSend).not.toHaveBeenCalled(); + expect(mockGetSignedUrl).toHaveBeenCalledTimes(1); + const [, command] = mockGetSignedUrl.mock.calls[0]; + // S3 signs each HTTP method separately; a GET-signed URL would be + // rejected when used with a HEAD request. + expect(command).toBeInstanceOf(MockHeadObjectCommand); + expect(command).toMatchObject({ + Bucket: 'test-bucket', + Key: 'device-agent/production/updates/CompAI-Device-Agent-1.0.5-arm64.zip', + }); + }); + }); + describe('downloadWindowsAgent', () => { it('should return stream, filename, and contentType on success', async () => { const mockStream = new Readable({ read() {} }); diff --git a/apps/api/src/device-agent/device-agent.service.ts b/apps/api/src/device-agent/device-agent.service.ts index 60cf93c963..4cb83ec735 100644 --- a/apps/api/src/device-agent/device-agent.service.ts +++ b/apps/api/src/device-agent/device-agent.service.ts @@ -9,6 +9,7 @@ import { GetObjectCommand, HeadObjectCommand, } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@/app/s3'; import { Readable } from 'stream'; const S3_ENV = process.env.DEVICE_AGENT_S3_ENV || 'production'; @@ -32,6 +33,20 @@ const CONTENT_TYPES: Record = { '.dmg': 'application/x-apple-diskimage', }; +/** + * Binaries are presigned + redirected so the client downloads directly from + * S3, bypassing proxy/function timeouts. Manifests are tiny enough to stream. + */ +const REDIRECT_EXTENSIONS = new Set([ + '.zip', + '.exe', + '.blockmap', + '.AppImage', + '.dmg', +]); + +const PRESIGNED_URL_TTL_SECONDS = 60 * 60; // 1 hour + function getExtension(filename: string): string { if (filename.endsWith('.AppImage')) return '.AppImage'; const dotIndex = filename.lastIndexOf('.'); @@ -167,17 +182,22 @@ export class DeviceAgentService { } } - async getUpdateFile({ filename }: { filename: string }): Promise<{ - stream: Readable; - contentType: string; - contentLength?: number; - }> { + async getUpdateFile({ + filename, + }: { + filename: string; + }): Promise { if (!isValidFilename(filename)) { throw new NotFoundException('Not found'); } const key = `${S3_UPDATES_PREFIX}/${filename}`; const ext = getExtension(filename); + + if (REDIRECT_EXTENSIONS.has(ext)) { + return { kind: 'redirect', url: await this.signUpdateUrl(key, 'GET') }; + } + const contentType = CONTENT_TYPES[ext] || 'application/octet-stream'; try { @@ -192,6 +212,7 @@ export class DeviceAgentService { } return { + kind: 'stream', stream: s3Response.Body as Readable, contentType, contentLength: @@ -214,13 +235,20 @@ export class DeviceAgentService { filename, }: { filename: string; - }): Promise<{ contentType: string; contentLength?: number }> { + }): Promise { if (!isValidFilename(filename)) { throw new NotFoundException('Not found'); } const key = `${S3_UPDATES_PREFIX}/${filename}`; const ext = getExtension(filename); + + if (REDIRECT_EXTENSIONS.has(ext)) { + // S3 signs each HTTP method separately — a GET-signed URL is rejected + // for HEAD with SignatureDoesNotMatch. + return { kind: 'redirect', url: await this.signUpdateUrl(key, 'HEAD') }; + } + const contentType = CONTENT_TYPES[ext] || 'application/octet-stream'; try { @@ -231,6 +259,7 @@ export class DeviceAgentService { const s3Response = await this.s3Client.send(command); return { + kind: 'stream', contentType, contentLength: typeof s3Response.ContentLength === 'number' @@ -241,4 +270,36 @@ export class DeviceAgentService { throw new NotFoundException('Not found'); } } + + private async signUpdateUrl( + key: string, + method: 'GET' | 'HEAD', + ): Promise { + const command = + method === 'HEAD' + ? new HeadObjectCommand({ + Bucket: this.fleetBucketName, + Key: key, + }) + : new GetObjectCommand({ + Bucket: this.fleetBucketName, + Key: key, + }); + return getSignedUrl(this.s3Client, command, { + expiresIn: PRESIGNED_URL_TTL_SECONDS, + }); + } } + +export type UpdateFileResult = + | { + kind: 'stream'; + stream: Readable; + contentType: string; + contentLength?: number; + } + | { kind: 'redirect'; url: string }; + +export type HeadUpdateFileResult = + | { kind: 'stream'; contentType: string; contentLength?: number } + | { kind: 'redirect'; url: string }; diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts new file mode 100644 index 0000000000..c6c69113f1 --- /dev/null +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts @@ -0,0 +1,244 @@ +import type { SyncEmployee } from '@trycompai/integration-platform'; + +const mockUserFindUnique = jest.fn(); +const mockUserCreate = jest.fn(); +const mockMemberFindFirst = jest.fn(); +const mockMemberCreate = jest.fn(); +const mockMemberFindMany = jest.fn(); +const mockMemberUpdate = jest.fn(); +const mockOrgRoleFindMany = jest.fn(); + +jest.mock('@trycompai/auth', () => ({ + BUILT_IN_ROLE_PERMISSIONS: { + owner: {}, + admin: {}, + auditor: {}, + employee: {}, + contractor: {}, + }, +})); + +jest.mock('@db', () => ({ + db: { + user: { + findUnique: (...args: unknown[]) => mockUserFindUnique(...args), + create: (...args: unknown[]) => mockUserCreate(...args), + }, + member: { + findFirst: (...args: unknown[]) => mockMemberFindFirst(...args), + create: (...args: unknown[]) => mockMemberCreate(...args), + findMany: (...args: unknown[]) => mockMemberFindMany(...args), + update: (...args: unknown[]) => mockMemberUpdate(...args), + }, + organizationRole: { + findMany: (...args: unknown[]) => mockOrgRoleFindMany(...args), + }, + }, +})); + +import { GenericEmployeeSyncService } from './generic-employee-sync.service'; + +describe('GenericEmployeeSyncService role validation', () => { + let service: GenericEmployeeSyncService; + + const baseEmployee = ( + overrides: Partial = {}, + ): SyncEmployee => ({ + email: 'new-hire@example.com', + name: 'New Hire', + status: 'active', + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + service = new GenericEmployeeSyncService(); + + // Default: user does not exist (will be created), no existing member, + // no other org members (skip phase 2). + mockUserFindUnique.mockResolvedValue(null); + mockUserCreate.mockResolvedValue({ + id: 'user_1', + email: 'new-hire@example.com', + }); + mockMemberFindFirst.mockResolvedValue(null); + mockMemberCreate.mockResolvedValue({ id: 'mem_1' }); + mockMemberFindMany.mockResolvedValue([]); + mockOrgRoleFindMany.mockResolvedValue([]); + }); + + it('persists a built-in role from the provider as-is', async () => { + await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ role: 'admin' })], + }); + + expect(mockMemberCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ role: 'admin' }), + }), + ); + }); + + it('persists a known custom role from the provider as-is', async () => { + mockOrgRoleFindMany.mockResolvedValue([ + { name: 'security-engineer' }, + ]); + + await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ role: 'security-engineer' })], + }); + + expect(mockMemberCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ role: 'security-engineer' }), + }), + ); + }); + + it('falls back to defaultRole when provider sends an unknown role (e.g. Microsoft jobTitle)', async () => { + await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ role: 'Senior Front End Engineer' })], + options: { defaultRole: 'employee' }, + }); + + expect(mockMemberCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ role: 'employee' }), + }), + ); + }); + + it('falls back to defaultRole when provider sends an empty role', async () => { + await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ role: '' })], + options: { defaultRole: 'contractor' }, + }); + + expect(mockMemberCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ role: 'contractor' }), + }), + ); + }); + + it('looks up custom roles scoped to the org being synced', async () => { + await service.processEmployees({ + organizationId: 'org_42', + employees: [baseEmployee({ role: 'employee' })], + }); + + expect(mockOrgRoleFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizationId: 'org_42' }, + select: { name: true }, + }), + ); + }); + + it('keeps only the valid tokens when role is comma-separated', async () => { + await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ role: 'admin,Senior Front End Engineer' })], + options: { defaultRole: 'employee' }, + }); + + expect(mockMemberCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ role: 'admin' }), + }), + ); + }); + + describe('limbo role self-heal on re-sync', () => { + it('heals an existing member whose role is entirely invalid', async () => { + mockUserFindUnique.mockResolvedValue({ + id: 'user_1', + email: 'mc@example.com', + }); + mockMemberFindFirst.mockResolvedValue({ + id: 'mem_1', + role: 'Senior Front End Engineer', + deactivated: false, + }); + + await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ email: 'mc@example.com', role: 'employee' })], + options: { defaultRole: 'employee' }, + }); + + expect(mockMemberUpdate).toHaveBeenCalledWith({ + where: { id: 'mem_1' }, + data: { role: 'employee' }, + }); + }); + + it('heals an existing member whose role mixes valid + invalid tokens', async () => { + mockUserFindUnique.mockResolvedValue({ + id: 'user_1', + email: 'mc@example.com', + }); + mockMemberFindFirst.mockResolvedValue({ + id: 'mem_1', + role: 'admin,Senior Front End Engineer', + deactivated: false, + }); + + await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ email: 'mc@example.com' })], + }); + + expect(mockMemberUpdate).toHaveBeenCalledWith({ + where: { id: 'mem_1' }, + data: { role: 'admin' }, + }); + }); + + it('does not touch an existing member whose role is already valid', async () => { + mockUserFindUnique.mockResolvedValue({ + id: 'user_1', + email: 'mc@example.com', + }); + mockMemberFindFirst.mockResolvedValue({ + id: 'mem_1', + role: 'admin', + deactivated: false, + }); + + await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ email: 'mc@example.com' })], + }); + + expect(mockMemberUpdate).not.toHaveBeenCalled(); + }); + + it('heals AND reactivates a deactivated member with a limbo role', async () => { + mockUserFindUnique.mockResolvedValue({ + id: 'user_1', + email: 'mc@example.com', + }); + mockMemberFindFirst.mockResolvedValue({ + id: 'mem_1', + role: 'Senior Front End Engineer', + deactivated: true, + }); + + await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ email: 'mc@example.com' })], + options: { allowReactivation: true, defaultRole: 'employee' }, + }); + + expect(mockMemberUpdate).toHaveBeenCalledWith({ + where: { id: 'mem_1' }, + data: { deactivated: false, isActive: true, role: 'employee' }, + }); + }); + }); +}); diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts index 8b0e01b452..9ee3c0c22c 100644 --- a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { db } from '@db'; import type { SyncEmployee } from '@trycompai/integration-platform'; +import { BUILT_IN_ROLE_PERMISSIONS } from '@trycompai/auth'; // ============================================================================ // Types @@ -76,6 +77,28 @@ export class GenericEmployeeSyncService { const protectedRoles = options.protectedRoles ?? DEFAULT_PROTECTED_ROLES; const providerName = options.providerName ?? 'provider'; + // Build the set of role identifiers we'll accept on this sync. Anything + // outside this set is dropped (e.g. a Microsoft DSL that mis-maps + // jobTitle into role would otherwise plant "Senior Front End Engineer" + // strings into member.role with no matching organization_role row). + const customRoles: { name: string }[] = await db.organizationRole.findMany({ + where: { organizationId }, + select: { name: true }, + }); + const validRoleNames = new Set([ + ...Object.keys(BUILT_IN_ROLE_PERMISSIONS), + ...customRoles.map((r) => r.name), + ]); + + const sanitizeRole = (raw: string | undefined | null): string => { + if (!raw) return defaultRole; + const tokens = raw + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0 && validRoleNames.has(t)); + return tokens.length > 0 ? tokens.join(',') : defaultRole; + }; + const results: SyncResult = { success: true, totalFound: employees.length, @@ -149,11 +172,27 @@ export class GenericEmployeeSyncService { }); if (existingMember) { + // Self-heal limbo roles: if the persisted member.role contains + // tokens that don't map to any valid role today (e.g. an Entra + // jobTitle planted by a misconfigured DSL pre-fix), drop them. + // We only ever shrink the role string here — never overwrite a + // valid assignment with whatever the provider sent. + const healedRole = sanitizeRole(existingMember.role); + const needsHeal = healedRole !== existingMember.role; + if (needsHeal) { + this.logger.warn( + `[GenericSync] Healing limbo role for ${normalizedEmail}: "${existingMember.role}" → "${healedRole}"`, + ); + } + if (existingMember.deactivated && allowReactivation) { - // Reactivate the member await db.member.update({ where: { id: existingMember.id }, - data: { deactivated: false, isActive: true }, + data: { + deactivated: false, + isActive: true, + ...(needsHeal ? { role: healedRole } : {}), + }, }); results.reactivated++; results.details.push({ @@ -161,6 +200,12 @@ export class GenericEmployeeSyncService { status: 'reactivated', }); } else { + if (needsHeal) { + await db.member.update({ + where: { id: existingMember.id }, + data: { role: healedRole }, + }); + } results.skipped++; results.details.push({ email: normalizedEmail, @@ -174,11 +219,17 @@ export class GenericEmployeeSyncService { } // Create new member + const sanitizedRole = sanitizeRole(employee.role); + if (employee.role && sanitizedRole !== employee.role) { + this.logger.warn( + `[GenericSync] Provider "${providerName}" sent unrecognized role "${employee.role}" for ${normalizedEmail}; falling back to "${sanitizedRole}"`, + ); + } await db.member.create({ data: { organizationId, userId: existingUser.id, - role: employee.role || defaultRole, + role: sanitizedRole, isActive: true, }, }); diff --git a/apps/portal/src/app/api/auth/get-session/route.ts b/apps/portal/src/app/api/auth/get-session/route.ts new file mode 100644 index 0000000000..8520b498c3 --- /dev/null +++ b/apps/portal/src/app/api/auth/get-session/route.ts @@ -0,0 +1,44 @@ +import { env } from '@/env.mjs'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const API_BASE = + env.BACKEND_API_URL || env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; + +/** + * Backwards-compat alias for device-agent installs (pre-PR #2222) that call + * `${portalUrl}/api/auth/get-session` to verify their session. Better-auth + * lives on the NestJS API now; cross-subdomain cookies (.trycomp.ai) make + * the same session token valid against api.trycomp.ai. + * + * TODO: Delete after the device-agent fleet has rolled past 1.0.5. + */ +export async function GET(req: NextRequest): Promise { + const headers: Record = {}; + const cookie = req.headers.get('cookie'); + if (cookie) headers['Cookie'] = cookie; + const authorization = req.headers.get('authorization'); + if (authorization) headers['Authorization'] = authorization; + + const response = await fetch(`${API_BASE}/api/auth/get-session`, { + method: 'GET', + headers, + redirect: 'manual', + }); + + // Headers must use append for Set-Cookie so that multiple cookies (e.g. + // session-refresh + cookie-cache) are preserved instead of comma-collapsed. + const responseHeaders = new Headers(); + const contentType = response.headers.get('Content-Type'); + if (contentType) responseHeaders.set('Content-Type', contentType); + for (const cookie of response.headers.getSetCookie()) { + responseHeaders.append('Set-Cookie', cookie); + } + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders, + }); +} diff --git a/apps/portal/src/app/api/device-agent/proxy.ts b/apps/portal/src/app/api/device-agent/proxy.ts index 3c31130dfb..cd7df07c22 100644 --- a/apps/portal/src/app/api/device-agent/proxy.ts +++ b/apps/portal/src/app/api/device-agent/proxy.ts @@ -37,7 +37,11 @@ export async function proxyToApi( headers['Cookie'] = cookie; } - const fetchOptions: RequestInit = { method, headers }; + // 'manual' so 3xx redirects (e.g. presigned-S3 URLs from the API for + // device-agent updates) reach the client instead of being followed by + // the proxy — the client downloads directly from S3, dodging Vercel + // function timeouts on multi-MB binaries. + const fetchOptions: RequestInit = { method, headers, redirect: 'manual' }; if (method === 'POST') { try { @@ -49,6 +53,18 @@ export async function proxyToApi( const response = await fetch(url, fetchOptions); + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('Location'); + const redirectHeaders: Record = {}; + if (location) redirectHeaders['Location'] = location; + const cacheControl = response.headers.get('Cache-Control'); + if (cacheControl) redirectHeaders['Cache-Control'] = cacheControl; + return new NextResponse(null, { + status: response.status, + headers: redirectHeaders, + }); + } + // Forward the response directly (preserves streaming for binary files) const responseHeaders: Record = {}; const contentType = response.headers.get('Content-Type');