From 542dcb3e4ae826d649d4dd866ac41dfbb8af48ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:10:57 -0400 Subject: [PATCH 1/5] feat(device-agent): implement device registration and authentication flow (#2281) - Added DeviceAgentAuthService to handle device authentication, including generating and exchanging authorization codes. - Implemented device registration logic with and without serial numbers, ensuring proper organization validation. - Created new DTOs for device registration, check-in, and authorization code exchange. - Updated DeviceAgentController to include endpoints for generating auth codes, registering devices, and checking in. - Introduced proxy functionality in the portal to forward device-agent requests to the NestJS API, enhancing security and session management. - Enhanced error handling and validation across new features, ensuring robust API interactions. Tests included for all new functionalities to ensure reliability and maintainability. Co-authored-by: Mariano Fuentes --- .github/workflows/device-agent-release.yml | 8 + CLAUDE.md | 9 +- apps/api/src/auth/auth.server.ts | 2 + .../device-agent-auth.service.spec.ts | 342 ++++++++++++++++++ .../device-agent/device-agent-auth.service.ts | 220 +++++++++++ apps/api/src/device-agent/device-agent-kv.ts | 55 +++ .../device-agent.controller.spec.ts | 6 +- .../device-agent/device-agent.controller.ts | 146 +++++++- .../src/device-agent/device-agent.module.ts | 3 +- .../src/device-agent/device-agent.service.ts | 122 ++++++- .../device-registration.helpers.ts | 132 +++++++ .../api/src/device-agent/dto/auth-code.dto.ts | 12 + apps/api/src/device-agent/dto/check-in.dto.ts | 64 ++++ .../src/device-agent/dto/exchange-code.dto.ts | 7 + .../device-agent/dto/register-device.dto.ts | 39 ++ .../(public)/auth/device-callback/page.tsx | 9 +- .../app/api/device-agent/auth-code/route.ts | 62 +--- .../app/api/device-agent/check-in/route.ts | 118 +----- .../api/device-agent/exchange-code/route.ts | 53 +-- .../device-agent/my-organizations/route.ts | 46 +-- apps/portal/src/app/api/device-agent/proxy.ts | 66 ++++ .../app/api/device-agent/register/route.ts | 184 +--------- .../src/app/api/device-agent/session.ts | 56 --- .../src/app/api/device-agent/status/route.ts | 56 +-- .../device-agent/updates/[filename]/route.ts | 125 +------ packages/device-agent/electron.vite.config.ts | 3 + packages/device-agent/src/main/auth.ts | 17 +- packages/device-agent/src/main/reporter.ts | 8 +- packages/device-agent/src/main/store.ts | 17 +- packages/device-agent/src/shared/constants.ts | 17 +- packages/docs/openapi.json | 201 ++++++++++ 31 files changed, 1489 insertions(+), 716 deletions(-) create mode 100644 apps/api/src/device-agent/device-agent-auth.service.spec.ts create mode 100644 apps/api/src/device-agent/device-agent-auth.service.ts create mode 100644 apps/api/src/device-agent/device-agent-kv.ts create mode 100644 apps/api/src/device-agent/device-registration.helpers.ts create mode 100644 apps/api/src/device-agent/dto/auth-code.dto.ts create mode 100644 apps/api/src/device-agent/dto/check-in.dto.ts create mode 100644 apps/api/src/device-agent/dto/exchange-code.dto.ts create mode 100644 apps/api/src/device-agent/dto/register-device.dto.ts create mode 100644 apps/portal/src/app/api/device-agent/proxy.ts delete mode 100644 apps/portal/src/app/api/device-agent/session.ts diff --git a/.github/workflows/device-agent-release.yml b/.github/workflows/device-agent-release.yml index d35272b826..aa5d8a09ce 100644 --- a/.github/workflows/device-agent-release.yml +++ b/.github/workflows/device-agent-release.yml @@ -18,6 +18,7 @@ jobs: tag_name: ${{ steps.version.outputs.tag_name }} is_prerelease: ${{ steps.version.outputs.is_prerelease }} portal_url: ${{ steps.version.outputs.portal_url }} + api_url: ${{ steps.version.outputs.api_url }} release_name: ${{ steps.version.outputs.release_name }} auto_update_url: ${{ steps.version.outputs.auto_update_url }} s3_env: ${{ steps.version.outputs.s3_env }} @@ -50,12 +51,14 @@ jobs: TAG_NAME="device-agent-v${NEXT_VERSION}" IS_PRERELEASE="false" PORTAL_URL="https://portal.trycomp.ai" + API_URL="https://api.trycomp.ai" RELEASE_NAME="Device Agent v${NEXT_VERSION}" S3_ENV="production" else TAG_NAME="device-agent-v${NEXT_VERSION}-staging.${GITHUB_RUN_NUMBER}" IS_PRERELEASE="true" PORTAL_URL="https://portal.staging.trycomp.ai" + API_URL="https://api.staging.trycomp.ai" RELEASE_NAME="Device Agent v${NEXT_VERSION} (Staging #${GITHUB_RUN_NUMBER})" S3_ENV="staging" fi @@ -67,6 +70,7 @@ jobs: echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT echo "portal_url=$PORTAL_URL" >> $GITHUB_OUTPUT + echo "api_url=$API_URL" >> $GITHUB_OUTPUT echo "release_name=$RELEASE_NAME" >> $GITHUB_OUTPUT echo "auto_update_url=$AUTO_UPDATE_URL" >> $GITHUB_OUTPUT echo "s3_env=$S3_ENV" >> $GITHUB_OUTPUT @@ -77,6 +81,7 @@ jobs: echo "Tag name: $TAG_NAME" echo "Pre-release: $IS_PRERELEASE" echo "Portal URL: $PORTAL_URL" + echo "API URL: $API_URL" echo "Auto-update URL: $AUTO_UPDATE_URL" echo "S3 env: $S3_ENV" @@ -112,6 +117,7 @@ jobs: - name: Build env: PORTAL_URL: ${{ needs.detect-version.outputs.portal_url }} + API_URL: ${{ needs.detect-version.outputs.api_url }} AGENT_VERSION: ${{ needs.detect-version.outputs.version }} run: bun run build @@ -170,6 +176,7 @@ jobs: - name: Build env: PORTAL_URL: ${{ needs.detect-version.outputs.portal_url }} + API_URL: ${{ needs.detect-version.outputs.api_url }} AGENT_VERSION: ${{ needs.detect-version.outputs.version }} run: bun run build @@ -319,6 +326,7 @@ jobs: - name: Build env: PORTAL_URL: ${{ needs.detect-version.outputs.portal_url }} + API_URL: ${{ needs.detect-version.outputs.api_url }} AGENT_VERSION: ${{ needs.detect-version.outputs.version }} run: bun run build diff --git a/CLAUDE.md b/CLAUDE.md index fc57dda3d5..02cf993199 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,10 +34,13 @@ packages/ ## Authentication & Session -- **Session-based auth only.** No JWT tokens. All requests use `credentials: 'include'` to send httpOnly session cookies. +- **Auth lives in `apps/api` (NestJS).** The API is the single source of truth for authentication via better-auth. All apps and packages that need to authenticate (app, portal, device-agent, etc.) MUST go through the API — never run a local better-auth instance or handle auth directly in a frontend app. +- **Session-based auth only.** No JWT tokens. Cross-subdomain cookies (`.trycomp.ai`) allow sessions to work across all apps. - **HybridAuthGuard** supports 3 methods in order: API Key (`x-api-key`), Service Token (`x-service-token`), Session (cookies). `@Public()` skips auth. -- **Client-side**: `apiClient` from `@/lib/api-client` (always sends cookies). -- **Server-side**: `serverApi` from `@/lib/api-server.ts`. +- **Client-side auth**: `authClient` (better-auth client) with `baseURL` pointing to the API, NOT the current app. +- **Client-side data**: `apiClient` from `@/lib/api-client` (always sends cookies). +- **Server-side data**: `serverApi` from `@/lib/api-server.ts`. +- **Server-side session checks**: Proxy to the API's `/api/auth/get-session` endpoint — do NOT instantiate better-auth locally. - **Raw `fetch()` to API**: MUST include `credentials: 'include'`, otherwise 401. ## API Architecture diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 6f53b8ec33..e7c876313f 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -5,6 +5,7 @@ import { db } from '@trycompai/db'; import { betterAuth } from 'better-auth'; import { prismaAdapter } from 'better-auth/adapters/prisma'; import { + bearer, emailOTP, magicLink, multiSession, @@ -302,6 +303,7 @@ export const auth = betterAuth({ }, }), multiSession(), + bearer(), ], socialProviders, user: { diff --git a/apps/api/src/device-agent/device-agent-auth.service.spec.ts b/apps/api/src/device-agent/device-agent-auth.service.spec.ts new file mode 100644 index 0000000000..ac0d724d12 --- /dev/null +++ b/apps/api/src/device-agent/device-agent-auth.service.spec.ts @@ -0,0 +1,342 @@ +import { ForbiddenException, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { DeviceAgentAuthService } from './device-agent-auth.service'; + +// Mock dependencies +jest.mock('@trycompai/db', () => ({ + db: { + member: { + findMany: jest.fn(), + findFirst: jest.fn(), + }, + device: { + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + findMany: jest.fn(), + }, + }, + Prisma: { + InputJsonValue: {}, + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { + api: { + getSession: jest.fn(), + }, + }, +})); + +jest.mock('./device-agent-kv', () => ({ + deviceAgentRedisClient: { + set: jest.fn().mockResolvedValue('OK'), + getdel: jest.fn(), + }, +})); + +import { db } from '@trycompai/db'; +import { auth } from '../auth/auth.server'; +import { deviceAgentRedisClient } from './device-agent-kv'; + +const mockDb = db as jest.Mocked; +const mockAuth = auth as jest.Mocked; +const mockKv = deviceAgentRedisClient as jest.Mocked; + +describe('DeviceAgentAuthService', () => { + let service: DeviceAgentAuthService; + + beforeEach(() => { + service = new DeviceAgentAuthService(); + jest.clearAllMocks(); + }); + + describe('generateAuthCode', () => { + it('should generate an auth code and store it in KV', async () => { + (mockAuth.api.getSession as jest.Mock).mockResolvedValue({ + user: { id: 'user-1' }, + session: { token: 'raw-session-token' }, + }); + + const headers = new Headers(); + headers.set('cookie', 'session=abc'); + + const result = await service.generateAuthCode({ headers, state: 'test-state' }); + + expect(result.code).toHaveLength(64); // 32 bytes hex + expect(mockKv.set).toHaveBeenCalledWith( + expect.stringMatching(/^device-auth:/), + expect.objectContaining({ + sessionToken: 'raw-session-token', + userId: 'user-1', + state: 'test-state', + }), + { ex: 120 }, + ); + }); + + it('should throw UnauthorizedException if no session', async () => { + (mockAuth.api.getSession as jest.Mock).mockResolvedValue(null); + + const headers = new Headers(); + await expect( + service.generateAuthCode({ headers, state: 'test-state' }), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('exchangeCode', () => { + it('should return session token for valid code', async () => { + (mockKv.getdel as jest.Mock).mockResolvedValue({ + sessionToken: 'session-123', + userId: 'user-1', + state: 'state-1', + createdAt: Date.now(), + }); + + const result = await service.exchangeCode({ code: 'valid-code' }); + + expect(result).toEqual({ + session_token: 'session-123', + user_id: 'user-1', + }); + expect(mockKv.getdel).toHaveBeenCalledWith('device-auth:valid-code'); + }); + + it('should throw UnauthorizedException for invalid/expired code', async () => { + (mockKv.getdel as jest.Mock).mockResolvedValue(null); + + await expect( + service.exchangeCode({ code: 'invalid-code' }), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('getMyOrganizations', () => { + it('should return user organizations', async () => { + (mockDb.member.findMany as jest.Mock).mockResolvedValue([ + { + organization: { id: 'org-1', name: 'Org One', slug: 'org-one' }, + role: 'admin', + }, + { + organization: { id: 'org-2', name: 'Org Two', slug: 'org-two' }, + role: 'employee', + }, + ]); + + const result = await service.getMyOrganizations({ userId: 'user-1' }); + + expect(result.organizations).toHaveLength(2); + expect(result.organizations[0]).toEqual({ + organizationId: 'org-1', + organizationName: 'Org One', + organizationSlug: 'org-one', + role: 'admin', + }); + expect(mockDb.member.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', deactivated: false }, + include: { organization: { select: { id: true, name: true, slug: true } } }, + }); + }); + }); + + describe('registerDevice', () => { + const baseDto = { + name: 'My Mac', + hostname: 'macbook.local', + platform: 'macos' as const, + osVersion: '14.0', + organizationId: 'org-1', + }; + + it('should throw ForbiddenException if user is not a member', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.registerDevice({ userId: 'user-1', dto: baseDto }), + ).rejects.toThrow(ForbiddenException); + }); + + it('should create a new device without serial number', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'member-1' }); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev-1' }); + + const result = await service.registerDevice({ userId: 'user-1', dto: baseDto }); + + expect(result).toEqual({ deviceId: 'dev-1' }); + expect(mockDb.device.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + hostname: 'macbook.local', + memberId: 'member-1', + organizationId: 'org-1', + serialNumber: null, + }), + }); + }); + + it('should create a new device with serial number', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'member-1' }); + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev-2' }); + + const dto = { ...baseDto, serialNumber: 'ABC123' }; + const result = await service.registerDevice({ userId: 'user-1', dto }); + + expect(result).toEqual({ deviceId: 'dev-2' }); + }); + + it('should update existing device when same member re-registers', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'member-1' }); + (mockDb.device.findUnique as jest.Mock).mockResolvedValue({ + id: 'dev-existing', + memberId: 'member-1', + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev-existing' }); + + const dto = { ...baseDto, serialNumber: 'ABC123' }; + const result = await service.registerDevice({ userId: 'user-1', dto }); + + expect(result).toEqual({ deviceId: 'dev-existing' }); + expect(mockDb.device.update).toHaveBeenCalled(); + }); + + it('should use fallback serial when serial belongs to different member', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'member-2' }); + (mockDb.device.findUnique as jest.Mock).mockResolvedValue({ + id: 'dev-other', + memberId: 'member-1', + }); + // No existing fallback + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev-fallback' }); + + const dto = { ...baseDto, serialNumber: 'GENERIC-SERIAL' }; + const result = await service.registerDevice({ userId: 'user-1', dto }); + + expect(result).toEqual({ deviceId: 'dev-fallback' }); + expect(mockDb.device.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + serialNumber: expect.stringMatching(/^fallback:GENERIC-SERIAL:/), + }), + }); + }); + }); + + describe('checkIn', () => { + it('should update device compliance fields', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev-1', + diskEncryptionEnabled: false, + antivirusEnabled: false, + passwordPolicySet: false, + screenLockEnabled: false, + checkDetails: {}, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ isCompliant: true }); + + const result = await service.checkIn({ + userId: 'user-1', + dto: { + deviceId: 'dev-1', + checks: [ + { checkType: 'disk_encryption', passed: true, checkedAt: new Date().toISOString() }, + { checkType: 'antivirus', passed: true, checkedAt: new Date().toISOString() }, + { checkType: 'password_policy', passed: true, checkedAt: new Date().toISOString() }, + { checkType: 'screen_lock', passed: true, checkedAt: new Date().toISOString() }, + ], + }, + }); + + expect(result.isCompliant).toBe(true); + expect(result.nextCheckIn).toBeDefined(); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev-1' }, + data: expect.objectContaining({ + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + isCompliant: true, + }), + }); + }); + + it('should throw NotFoundException if device not found', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.checkIn({ + userId: 'user-1', + dto: { + deviceId: 'dev-missing', + checks: [ + { checkType: 'disk_encryption', passed: true, checkedAt: new Date().toISOString() }, + ], + }, + }), + ).rejects.toThrow(NotFoundException); + }); + + it('should set isCompliant to false when not all checks pass', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev-1', + diskEncryptionEnabled: false, + antivirusEnabled: false, + passwordPolicySet: false, + screenLockEnabled: false, + checkDetails: {}, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ isCompliant: false }); + + const result = await service.checkIn({ + userId: 'user-1', + dto: { + deviceId: 'dev-1', + checks: [ + { checkType: 'disk_encryption', passed: true, checkedAt: new Date().toISOString() }, + { checkType: 'antivirus', passed: false, checkedAt: new Date().toISOString() }, + ], + }, + }); + + expect(result.isCompliant).toBe(false); + }); + }); + + describe('getDeviceStatus', () => { + it('should return all devices when no deviceId specified', async () => { + const devices = [ + { id: 'dev-1', name: 'Mac 1' }, + { id: 'dev-2', name: 'Mac 2' }, + ]; + (mockDb.device.findMany as jest.Mock).mockResolvedValue(devices); + + const result = await service.getDeviceStatus({ userId: 'user-1' }); + + expect(result).toEqual({ devices }); + }); + + it('should return a specific device', async () => { + const device = { id: 'dev-1', name: 'Mac 1' }; + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(device); + + const result = await service.getDeviceStatus({ + userId: 'user-1', + deviceId: 'dev-1', + }); + + expect(result).toEqual({ device }); + }); + + it('should throw NotFoundException for missing device', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.getDeviceStatus({ userId: 'user-1', deviceId: 'dev-missing' }), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/device-agent/device-agent-auth.service.ts b/apps/api/src/device-agent/device-agent-auth.service.ts new file mode 100644 index 0000000000..9be5f8d1da --- /dev/null +++ b/apps/api/src/device-agent/device-agent-auth.service.ts @@ -0,0 +1,220 @@ +import { + ForbiddenException, + Injectable, + Logger, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { randomBytes } from 'node:crypto'; +import { db, Prisma } from '@trycompai/db'; +import { auth } from '../auth/auth.server'; +import { deviceAgentRedisClient } from './device-agent-kv'; +import { + registerWithSerial, + registerWithoutSerial, +} from './device-registration.helpers'; +import { RegisterDeviceDto } from './dto/register-device.dto'; +import { CheckInDto } from './dto/check-in.dto'; + +interface StoredAuthCode { + sessionToken: string; + userId: string; + state: string; + createdAt: number; +} + +const CHECK_TYPE_TO_FIELD: Record = { + disk_encryption: 'diskEncryptionEnabled', + antivirus: 'antivirusEnabled', + password_policy: 'passwordPolicySet', + screen_lock: 'screenLockEnabled', +}; + +@Injectable() +export class DeviceAgentAuthService { + private readonly logger = new Logger(DeviceAgentAuthService.name); + + async generateAuthCode({ + headers, + state, + }: { + headers: Headers; + state: string; + }) { + const session = await auth.api.getSession({ headers }); + + if (!session?.user) { + throw new UnauthorizedException('No active session'); + } + + const code = randomBytes(32).toString('hex'); + + await deviceAgentRedisClient.set( + `device-auth:${code}`, + { + sessionToken: session.session.token, + userId: session.user.id, + state, + createdAt: Date.now(), + }, + { ex: 120 }, + ); + + return { code }; + } + + async exchangeCode({ code }: { code: string }) { + const stored = await deviceAgentRedisClient.getdel( + `device-auth:${code}`, + ); + + if (!stored) { + throw new UnauthorizedException( + 'Invalid or expired authorization code', + ); + } + + return { + session_token: stored.sessionToken, + user_id: stored.userId, + }; + } + + async getMyOrganizations({ userId }: { userId: string }) { + const memberships = await db.member.findMany({ + where: { userId, deactivated: false }, + include: { + organization: { + select: { id: true, name: true, slug: true }, + }, + }, + }); + + const organizations = memberships.map((m) => ({ + organizationId: m.organization.id, + organizationName: m.organization.name, + organizationSlug: m.organization.slug, + role: m.role, + })); + + return { organizations }; + } + + async registerDevice({ + userId, + dto, + }: { + userId: string; + dto: RegisterDeviceDto; + }) { + const member = await db.member.findFirst({ + where: { + userId, + organizationId: dto.organizationId, + deactivated: false, + }, + }); + + if (!member) { + throw new ForbiddenException('Not a member of this organization'); + } + + const device = dto.serialNumber + ? await registerWithSerial({ member, dto }) + : await registerWithoutSerial({ member, dto }); + + return { deviceId: device.id }; + } + + async checkIn({ userId, dto }: { userId: string; dto: CheckInDto }) { + const device = await db.device.findFirst({ + where: { + id: dto.deviceId, + member: { userId, deactivated: false }, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + const checkFields: Record = { + diskEncryptionEnabled: device.diskEncryptionEnabled, + antivirusEnabled: device.antivirusEnabled, + passwordPolicySet: device.passwordPolicySet, + screenLockEnabled: device.screenLockEnabled, + }; + + const checkDetails: Record = + (device.checkDetails as Record) ?? {}; + + for (const check of dto.checks) { + const field = CHECK_TYPE_TO_FIELD[check.checkType]; + if (field) { + checkFields[field] = check.passed; + } + checkDetails[check.checkType] = { + ...check.details, + passed: check.passed, + checkedAt: check.checkedAt, + }; + } + + const isCompliant = + checkFields.diskEncryptionEnabled && + checkFields.antivirusEnabled && + checkFields.passwordPolicySet && + checkFields.screenLockEnabled; + + await db.device.update({ + where: { id: dto.deviceId }, + data: { + ...checkFields, + checkDetails: checkDetails as Prisma.InputJsonValue, + isCompliant, + lastCheckIn: new Date(), + ...(dto.agentVersion ? { agentVersion: dto.agentVersion } : {}), + }, + }); + + return { + isCompliant, + nextCheckIn: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }; + } + + async getDeviceStatus({ + userId, + deviceId, + organizationId, + }: { + userId: string; + deviceId?: string; + organizationId?: string; + }) { + if (!deviceId) { + const devices = await db.device.findMany({ + where: { + member: { userId, deactivated: false }, + ...(organizationId ? { organizationId } : {}), + }, + orderBy: { installedAt: 'desc' }, + }); + + return { devices }; + } + + const device = await db.device.findFirst({ + where: { + id: deviceId, + member: { userId, deactivated: false }, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + return { device }; + } +} diff --git a/apps/api/src/device-agent/device-agent-kv.ts b/apps/api/src/device-agent/device-agent-kv.ts new file mode 100644 index 0000000000..8bd161f89c --- /dev/null +++ b/apps/api/src/device-agent/device-agent-kv.ts @@ -0,0 +1,55 @@ +import { Redis } from '@upstash/redis'; + +class InMemoryRedis { + private storage = new Map(); + + async get(key: string): Promise { + const record = this.storage.get(key); + if (!record) return null; + if (record.expiresAt && record.expiresAt <= Date.now()) { + this.storage.delete(key); + return null; + } + return record.value as T; + } + + async set( + key: string, + value: unknown, + options?: { ex?: number }, + ): Promise<'OK'> { + const expiresAt = options?.ex ? Date.now() + options.ex * 1000 : undefined; + this.storage.set(key, { value, expiresAt }); + return 'OK'; + } + + async getdel(key: string): Promise { + const value = await this.get(key); + if (value !== null) { + this.storage.delete(key); + } + return value; + } + + async del(key: string): Promise { + const existed = this.storage.delete(key); + return existed ? 1 : 0; + } +} + +const hasUpstashConfig = + !!process.env.UPSTASH_REDIS_REST_URL && + !!process.env.UPSTASH_REDIS_REST_TOKEN; + +export const deviceAgentRedisClient: Pick< + Redis, + 'get' | 'set' | 'getdel' | 'del' +> = hasUpstashConfig + ? new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, + }) + : (new InMemoryRedis() as unknown as Pick< + Redis, + 'get' | 'set' | 'getdel' | 'del' + >); 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 97bcbb56d5..7d083f1148 100644 --- a/apps/api/src/device-agent/device-agent.controller.spec.ts +++ b/apps/api/src/device-agent/device-agent.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { StreamableFile } from '@nestjs/common'; import { DeviceAgentController } from './device-agent.controller'; +import { DeviceAgentAuthService } from './device-agent-auth.service'; import { DeviceAgentService } from './device-agent.service'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; @@ -46,7 +47,10 @@ describe('DeviceAgentController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [DeviceAgentController], - providers: [{ provide: DeviceAgentService, useValue: mockService }], + providers: [ + { provide: DeviceAgentService, useValue: mockService }, + { provide: DeviceAgentAuthService, useValue: {} }, + ], }) .overrideGuard(HybridAuthGuard) .useValue(mockGuard) diff --git a/apps/api/src/device-agent/device-agent.controller.ts b/apps/api/src/device-agent/device-agent.controller.ts index 7b24ff74a7..339f7cc5b5 100644 --- a/apps/api/src/device-agent/device-agent.controller.ts +++ b/apps/api/src/device-agent/device-agent.controller.ts @@ -1,36 +1,151 @@ import { + Body, Controller, Get, - UseGuards, - StreamableFile, + Head, + Param, + Post, + Query, + Req, Response, + StreamableFile, + UseGuards, } from '@nestjs/common'; -import { - ApiOperation, - ApiResponse, - ApiSecurity, - ApiTags, -} from '@nestjs/swagger'; -import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; +import { ApiOperation, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { AuthContext, OrganizationId, UserId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; +import { Public } from '../auth/public.decorator'; import { RequirePermission } from '../auth/require-permission.decorator'; +import { SkipOrgCheck } from '../auth/skip-org-check.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; +import { DeviceAgentAuthService } from './device-agent-auth.service'; import { DeviceAgentService } from './device-agent.service'; +import { AuthCodeDto } from './dto/auth-code.dto'; +import { CheckInDto } from './dto/check-in.dto'; +import { ExchangeCodeDto } from './dto/exchange-code.dto'; +import { RegisterDeviceDto } from './dto/register-device.dto'; import { DEVICE_AGENT_OPERATIONS } from './schemas/device-agent-operations'; import { DOWNLOAD_MAC_AGENT_RESPONSES } from './schemas/download-mac-agent.responses'; import { DOWNLOAD_WINDOWS_AGENT_RESPONSES } from './schemas/download-windows-agent.responses'; import type { Response as ExpressResponse } from 'express'; +import type { Request as ExpressRequest } from 'express'; @ApiTags('Device Agent') @Controller({ path: 'device-agent', version: '1' }) -@UseGuards(HybridAuthGuard, PermissionGuard) -@RequirePermission('app', 'read') -@ApiSecurity('apikey') export class DeviceAgentController { - constructor(private readonly deviceAgentService: DeviceAgentService) {} + constructor( + private readonly deviceAgentService: DeviceAgentService, + private readonly deviceAgentAuthService: DeviceAgentAuthService, + ) {} + + // --- Public endpoints (no auth) --- + + @Post('exchange-code') + @Public() + async exchangeCode(@Body() dto: ExchangeCodeDto) { + return this.deviceAgentAuthService.exchangeCode({ code: dto.code }); + } + + @Get('updates/:filename') + @Public() + async getUpdateFile( + @Param('filename') filename: string, + @Response({ passthrough: true }) res: ExpressResponse, + ) { + const result = await this.deviceAgentService.getUpdateFile({ filename }); + + res.set({ + 'Content-Type': result.contentType, + 'Cache-Control': 'public, max-age=300', + ...(result.contentLength + ? { 'Content-Length': result.contentLength.toString() } + : {}), + }); + + return new StreamableFile(result.stream); + } + + @Head('updates/:filename') + @Public() + async headUpdateFile( + @Param('filename') filename: string, + @Response({ passthrough: true }) res: ExpressResponse, + ) { + const result = await this.deviceAgentService.headUpdateFile({ filename }); + + res.set({ + 'Content-Type': result.contentType, + 'Cache-Control': 'public, max-age=300', + ...(result.contentLength + ? { 'Content-Length': result.contentLength.toString() } + : {}), + }); + + return ''; + } + + // --- Session-only endpoints (no org check) --- + + @Post('auth-code') + @UseGuards(HybridAuthGuard) + @SkipOrgCheck() + async generateAuthCode(@Req() req: ExpressRequest, @Body() dto: AuthCodeDto) { + // Construct Web API Headers from Express IncomingHttpHeaders + const headers = new Headers(); + const authHeader = req.headers['authorization']; + if (authHeader) headers.set('authorization', authHeader as string); + const cookieHeader = req.headers['cookie']; + if (cookieHeader) headers.set('cookie', cookieHeader); + + return this.deviceAgentAuthService.generateAuthCode({ + headers, + state: dto.state, + }); + } + + @Get('my-organizations') + @UseGuards(HybridAuthGuard) + @SkipOrgCheck() + async getMyOrganizations(@UserId() userId: string) { + return this.deviceAgentAuthService.getMyOrganizations({ userId }); + } + + @Post('register') + @UseGuards(HybridAuthGuard) + @SkipOrgCheck() + async registerDevice(@UserId() userId: string, @Body() dto: RegisterDeviceDto) { + return this.deviceAgentAuthService.registerDevice({ userId, dto }); + } + + @Post('check-in') + @UseGuards(HybridAuthGuard) + @SkipOrgCheck() + async checkIn(@UserId() userId: string, @Body() dto: CheckInDto) { + return this.deviceAgentAuthService.checkIn({ userId, dto }); + } + + @Get('status') + @UseGuards(HybridAuthGuard) + @SkipOrgCheck() + async getDeviceStatus( + @UserId() userId: string, + @Query('deviceId') deviceId?: string, + @Query('organizationId') organizationId?: string, + ) { + return this.deviceAgentAuthService.getDeviceStatus({ + userId, + deviceId, + organizationId, + }); + } + + // --- RBAC-protected endpoints (existing) --- @Get('mac') + @UseGuards(HybridAuthGuard, PermissionGuard) + @RequirePermission('app', 'read') + @ApiSecurity('apikey') @ApiOperation(DEVICE_AGENT_OPERATIONS.downloadMacAgent) @ApiResponse(DOWNLOAD_MAC_AGENT_RESPONSES[200]) @ApiResponse(DOWNLOAD_MAC_AGENT_RESPONSES[401]) @@ -44,7 +159,6 @@ export class DeviceAgentController { const { stream, filename, contentType } = await this.deviceAgentService.downloadMacAgent(); - // Set headers for file download res.set({ 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`, @@ -57,6 +171,9 @@ export class DeviceAgentController { } @Get('windows') + @UseGuards(HybridAuthGuard, PermissionGuard) + @RequirePermission('app', 'read') + @ApiSecurity('apikey') @ApiOperation(DEVICE_AGENT_OPERATIONS.downloadWindowsAgent) @ApiResponse(DOWNLOAD_WINDOWS_AGENT_RESPONSES[200]) @ApiResponse(DOWNLOAD_WINDOWS_AGENT_RESPONSES[401]) @@ -70,7 +187,6 @@ export class DeviceAgentController { const { stream, filename, contentType } = await this.deviceAgentService.downloadWindowsAgent(); - // Set headers for file download res.set({ 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`, diff --git a/apps/api/src/device-agent/device-agent.module.ts b/apps/api/src/device-agent/device-agent.module.ts index 77b4ef4fe3..bb737e9fe3 100644 --- a/apps/api/src/device-agent/device-agent.module.ts +++ b/apps/api/src/device-agent/device-agent.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { DeviceAgentAuthService } from './device-agent-auth.service'; import { DeviceAgentController } from './device-agent.controller'; import { DeviceAgentService } from './device-agent.service'; @Module({ imports: [AuthModule], controllers: [DeviceAgentController], - providers: [DeviceAgentService], + providers: [DeviceAgentService, DeviceAgentAuthService], exports: [DeviceAgentService], }) export class DeviceAgentModule {} diff --git a/apps/api/src/device-agent/device-agent.service.ts b/apps/api/src/device-agent/device-agent.service.ts index 019e01c828..d763d8c1b9 100644 --- a/apps/api/src/device-agent/device-agent.service.ts +++ b/apps/api/src/device-agent/device-agent.service.ts @@ -4,9 +4,51 @@ import { NotFoundException, Logger, } from '@nestjs/common'; -import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; +import { + S3Client, + GetObjectCommand, + HeadObjectCommand, +} from '@aws-sdk/client-s3'; import { Readable } from 'stream'; +const S3_ENV = process.env.DEVICE_AGENT_S3_ENV || 'production'; +const S3_UPDATES_PREFIX = `device-agent/${S3_ENV}/updates`; + +const ALLOWED_EXTENSIONS = new Set([ + '.yml', + '.zip', + '.exe', + '.blockmap', + '.AppImage', + '.dmg', +]); + +const CONTENT_TYPES: Record = { + '.yml': 'text/yaml', + '.zip': 'application/zip', + '.exe': 'application/octet-stream', + '.blockmap': 'application/octet-stream', + '.AppImage': 'application/octet-stream', + '.dmg': 'application/x-apple-diskimage', +}; + +function getExtension(filename: string): string { + if (filename.endsWith('.AppImage')) return '.AppImage'; + const dotIndex = filename.lastIndexOf('.'); + return dotIndex >= 0 ? filename.slice(dotIndex) : ''; +} + +function isValidFilename(filename: string): boolean { + if ( + filename.includes('..') || + filename.includes('/') || + filename.includes('\\') + ) { + return false; + } + return ALLOWED_EXTENSIONS.has(getExtension(filename)); +} + @Injectable() export class DeviceAgentService { private readonly logger = new Logger(DeviceAgentService.name); @@ -14,9 +56,6 @@ export class DeviceAgentService { private fleetBucketName: string; constructor() { - // AWS configuration is validated at startup via ConfigModule - // For device agents, we use the FLEET_AGENT_BUCKET_NAME if available, - // otherwise fall back to the main bucket this.fleetBucketName = process.env.FLEET_AGENT_BUCKET_NAME || process.env.APP_AWS_BUCKET_NAME!; this.s3Client = new S3Client({ @@ -127,4 +166,79 @@ export class DeviceAgentService { ); } } + + async getUpdateFile({ + filename, + }: { + filename: string; + }): Promise<{ stream: Readable; contentType: string; contentLength?: number }> { + if (!isValidFilename(filename)) { + throw new NotFoundException('Not found'); + } + + const key = `${S3_UPDATES_PREFIX}/${filename}`; + const ext = getExtension(filename); + const contentType = CONTENT_TYPES[ext] || 'application/octet-stream'; + + try { + const command = new GetObjectCommand({ + Bucket: this.fleetBucketName, + Key: key, + }); + const s3Response = await this.s3Client.send(command); + + if (!s3Response.Body) { + throw new NotFoundException('Not found'); + } + + return { + stream: s3Response.Body as Readable, + contentType, + contentLength: + typeof s3Response.ContentLength === 'number' + ? s3Response.ContentLength + : undefined, + }; + } catch (error) { + if (error instanceof NotFoundException) throw error; + const s3Error = error as { name?: string }; + if (s3Error.name === 'NoSuchKey') { + throw new NotFoundException('Not found'); + } + this.logger.error('Error serving update file:', { key, error }); + throw new InternalServerErrorException('Internal server error'); + } + } + + async headUpdateFile({ + filename, + }: { + filename: string; + }): Promise<{ contentType: string; contentLength?: number }> { + if (!isValidFilename(filename)) { + throw new NotFoundException('Not found'); + } + + const key = `${S3_UPDATES_PREFIX}/${filename}`; + const ext = getExtension(filename); + const contentType = CONTENT_TYPES[ext] || 'application/octet-stream'; + + try { + const command = new HeadObjectCommand({ + Bucket: this.fleetBucketName, + Key: key, + }); + const s3Response = await this.s3Client.send(command); + + return { + contentType, + contentLength: + typeof s3Response.ContentLength === 'number' + ? s3Response.ContentLength + : undefined, + }; + } catch { + throw new NotFoundException('Not found'); + } + } } diff --git a/apps/api/src/device-agent/device-registration.helpers.ts b/apps/api/src/device-agent/device-registration.helpers.ts new file mode 100644 index 0000000000..bc38d99132 --- /dev/null +++ b/apps/api/src/device-agent/device-registration.helpers.ts @@ -0,0 +1,132 @@ +import { randomUUID } from 'node:crypto'; +import { db } from '@trycompai/db'; +import { RegisterDeviceDto } from './dto/register-device.dto'; + +interface MemberRef { + id: string; +} + +function buildUpdateData(dto: RegisterDeviceDto) { + return { + name: dto.name, + platform: dto.platform, + osVersion: dto.osVersion, + hardwareModel: dto.hardwareModel, + agentVersion: dto.agentVersion, + }; +} + +export async function registerWithSerial({ + member, + dto, +}: { + member: MemberRef; + dto: RegisterDeviceDto; +}) { + const existing = await db.device.findUnique({ + where: { + serialNumber_organizationId: { + serialNumber: dto.serialNumber!, + organizationId: dto.organizationId, + }, + }, + select: { id: true, memberId: true }, + }); + + if (existing && existing.memberId !== member.id) { + return handleFallbackSerial({ member, dto }); + } + + const updateData = buildUpdateData(dto); + + if (existing) { + return db.device.update({ + where: { id: existing.id }, + data: { ...updateData, hostname: dto.hostname }, + }); + } + + return db.device.create({ + data: { + ...updateData, + hostname: dto.hostname, + serialNumber: dto.serialNumber!, + memberId: member.id, + organizationId: dto.organizationId, + }, + }); +} + +async function handleFallbackSerial({ + member, + dto, +}: { + member: MemberRef; + dto: RegisterDeviceDto; +}) { + const fallback = await db.device.findFirst({ + where: { + hostname: dto.hostname, + memberId: member.id, + organizationId: dto.organizationId, + serialNumber: { startsWith: `fallback:${dto.serialNumber}:` }, + }, + }); + + const updateData = buildUpdateData(dto); + + if (fallback) { + return db.device.update({ + where: { id: fallback.id }, + data: updateData, + }); + } + + const fallbackSerial = `fallback:${dto.serialNumber}:${randomUUID()}`; + + return db.device.create({ + data: { + ...updateData, + hostname: dto.hostname, + serialNumber: fallbackSerial, + memberId: member.id, + organizationId: dto.organizationId, + }, + }); +} + +export async function registerWithoutSerial({ + member, + dto, +}: { + member: MemberRef; + dto: RegisterDeviceDto; +}) { + const existing = await db.device.findFirst({ + where: { + hostname: dto.hostname, + memberId: member.id, + organizationId: dto.organizationId, + serialNumber: null, + }, + }); + + const updateData = buildUpdateData(dto); + + if (existing) { + return db.device.update({ + where: { id: existing.id }, + data: updateData, + }); + } + + return db.device.create({ + data: { + ...updateData, + hostname: dto.hostname, + serialNumber: null, + memberId: member.id, + organizationId: dto.organizationId, + }, + }); +} diff --git a/apps/api/src/device-agent/dto/auth-code.dto.ts b/apps/api/src/device-agent/dto/auth-code.dto.ts new file mode 100644 index 0000000000..33983b4e5a --- /dev/null +++ b/apps/api/src/device-agent/dto/auth-code.dto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsString, Max, Min, MinLength } from 'class-validator'; + +export class AuthCodeDto { + @IsInt() + @Min(1) + @Max(65535) + callback_port: number; + + @IsString() + @MinLength(1) + state: string; +} diff --git a/apps/api/src/device-agent/dto/check-in.dto.ts b/apps/api/src/device-agent/dto/check-in.dto.ts new file mode 100644 index 0000000000..06bafa1d4e --- /dev/null +++ b/apps/api/src/device-agent/dto/check-in.dto.ts @@ -0,0 +1,64 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsDateString, + IsEnum, + IsObject, + IsOptional, + IsString, + MaxLength, + MinLength, + ValidateNested, +} from 'class-validator'; + +class CheckDetailsDto { + @IsString() + @MaxLength(100) + method: string; + + @IsString() + @MaxLength(2000) + raw: string; + + @IsString() + @MaxLength(1000) + message: string; + + @IsString() + @MaxLength(500) + @IsOptional() + exception?: string; +} + +export class CheckResultDto { + @IsEnum(['disk_encryption', 'antivirus', 'password_policy', 'screen_lock']) + checkType: 'disk_encryption' | 'antivirus' | 'password_policy' | 'screen_lock'; + + @IsBoolean() + passed: boolean; + + @ValidateNested() + @Type(() => CheckDetailsDto) + @IsObject() + @IsOptional() + details?: CheckDetailsDto; + + @IsDateString() + checkedAt: string; +} + +export class CheckInDto { + @IsString() + @MinLength(1) + deviceId: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CheckResultDto) + checks: CheckResultDto[]; + + @IsString() + @IsOptional() + agentVersion?: string; +} diff --git a/apps/api/src/device-agent/dto/exchange-code.dto.ts b/apps/api/src/device-agent/dto/exchange-code.dto.ts new file mode 100644 index 0000000000..9c67b74d24 --- /dev/null +++ b/apps/api/src/device-agent/dto/exchange-code.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class ExchangeCodeDto { + @IsString() + @MinLength(1) + code: string; +} diff --git a/apps/api/src/device-agent/dto/register-device.dto.ts b/apps/api/src/device-agent/dto/register-device.dto.ts new file mode 100644 index 0000000000..eb28500738 --- /dev/null +++ b/apps/api/src/device-agent/dto/register-device.dto.ts @@ -0,0 +1,39 @@ +import { + IsEnum, + IsOptional, + IsString, + MinLength, +} from 'class-validator'; + +export class RegisterDeviceDto { + @IsString() + @MinLength(1) + name: string; + + @IsString() + @MinLength(1) + hostname: string; + + @IsEnum(['macos', 'windows', 'linux']) + platform: 'macos' | 'windows' | 'linux'; + + @IsString() + @MinLength(1) + osVersion: string; + + @IsString() + @MinLength(1) + organizationId: string; + + @IsString() + @IsOptional() + serialNumber?: string; + + @IsString() + @IsOptional() + hardwareModel?: string; + + @IsString() + @IsOptional() + agentVersion?: string; +} diff --git a/apps/portal/src/app/(public)/auth/device-callback/page.tsx b/apps/portal/src/app/(public)/auth/device-callback/page.tsx index a636040d8d..be2f93c82a 100644 --- a/apps/portal/src/app/(public)/auth/device-callback/page.tsx +++ b/apps/portal/src/app/(public)/auth/device-callback/page.tsx @@ -8,6 +8,8 @@ import { useEffect, useState } from 'react'; type Status = 'redirecting' | 'success' | 'error'; +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; + export default function DeviceCallbackPage() { const searchParams = useSearchParams(); const [status, setStatus] = useState('redirecting'); @@ -32,16 +34,17 @@ export default function DeviceCallbackPage() { async function exchangeAndRedirect() { try { - // Generate an auth code using the authenticated session - const response = await fetch('/api/device-agent/auth-code', { + // Generate an auth code by calling the NestJS API cross-origin + const response = await fetch(`${apiUrl}/v1/device-agent/auth-code`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ callback_port: port, state }), }); if (!response.ok) { const data = await response.json().catch(() => ({})); - throw new Error(data.error || `Server returned ${response.status}`); + throw new Error(data.error || data.message || `Server returned ${response.status}`); } const { code } = await response.json(); diff --git a/apps/portal/src/app/api/device-agent/auth-code/route.ts b/apps/portal/src/app/api/device-agent/auth-code/route.ts index 7e33763247..a77ee047b7 100644 --- a/apps/portal/src/app/api/device-agent/auth-code/route.ts +++ b/apps/portal/src/app/api/device-agent/auth-code/route.ts @@ -1,64 +1,8 @@ -import { auth } from '@/app/lib/auth'; -import { client as kv } from '@comp/kv'; -import { randomBytes } from 'crypto'; -import { type NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; +import { proxyToApi } from '../proxy'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const authCodeSchema = z.object({ - callback_port: z.number().int().min(1).max(65535), - state: z.string().min(1), -}); - -/** - * Generates a short-lived authorization code for the device agent. - * Called by the portal frontend after successful login when device_auth=true. - * The code can be exchanged for a session token via /api/device-agent/exchange-code. - */ -export async function POST(req: NextRequest) { - try { - const session = await auth.api.getSession({ headers: req.headers }); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = await req.json(); - const parsed = authCodeSchema.safeParse(body); - - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request body', details: parsed.error.flatten() }, - { status: 400 }, - ); - } - - const { state } = parsed.data; - - // Use the raw session token from the database (not the signed cookie value). - // The bearer plugin expects the raw token and signs it internally. - const sessionToken = session.session.token; - - // Generate a single-use authorization code - const code = randomBytes(32).toString('hex'); - - // Store in KV with 2-minute expiry - await kv.set( - `device-auth:${code}`, - { - sessionToken, - userId: session.user.id, - state, - createdAt: Date.now(), - }, - { ex: 120 }, - ); - - return NextResponse.json({ code }); - } catch (error) { - console.error('Error generating device auth code:', error); - return NextResponse.json({ error: 'Failed to generate auth code' }, { status: 500 }); - } +export async function POST(req: Request) { + return proxyToApi(req, '/v1/device-agent/auth-code', 'POST'); } diff --git a/apps/portal/src/app/api/device-agent/check-in/route.ts b/apps/portal/src/app/api/device-agent/check-in/route.ts index 0418a9f62f..422478cc59 100644 --- a/apps/portal/src/app/api/device-agent/check-in/route.ts +++ b/apps/portal/src/app/api/device-agent/check-in/route.ts @@ -1,120 +1,8 @@ -import { db } from '@db'; -import { type NextRequest, NextResponse } from 'next/server'; -import type { Prisma } from '@db'; -import { z } from 'zod'; -import { getDeviceAgentSession } from '../session'; +import { proxyToApi } from '../proxy'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const checkResultSchema = z.object({ - checkType: z.enum(['disk_encryption', 'antivirus', 'password_policy', 'screen_lock']), - passed: z.boolean(), - details: z - .object({ - method: z.string().max(100), - raw: z.string().max(2000), - message: z.string().max(1000), - exception: z.string().max(500).optional(), - }) - .optional(), - checkedAt: z.string().datetime(), -}); - -const checkInSchema = z.object({ - deviceId: z.string().min(1), - checks: z.array(checkResultSchema).min(1), - agentVersion: z.string().optional(), -}); - -export async function POST(req: NextRequest) { - try { - const session = await getDeviceAgentSession(req); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = await req.json(); - const parsed = checkInSchema.safeParse(body); - - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request body', details: parsed.error.flatten() }, - { status: 400 }, - ); - } - - const { deviceId, checks, agentVersion } = parsed.data; - - // Verify the device belongs to an active member of the authenticated user - const device = await db.device.findFirst({ - where: { - id: deviceId, - member: { - userId: session.user.id, - deactivated: false, - }, - }, - }); - - if (!device) { - return NextResponse.json({ error: 'Device not found' }, { status: 404 }); - } - - // Build check fields from results - const checkFields: Record = { - diskEncryptionEnabled: device.diskEncryptionEnabled, - antivirusEnabled: device.antivirusEnabled, - passwordPolicySet: device.passwordPolicySet, - screenLockEnabled: device.screenLockEnabled, - }; - - const checkDetails: Record = (device.checkDetails as Record) ?? {}; - - const checkTypeToField: Record = { - disk_encryption: 'diskEncryptionEnabled', - antivirus: 'antivirusEnabled', - password_policy: 'passwordPolicySet', - screen_lock: 'screenLockEnabled', - }; - - for (const check of checks) { - const field = checkTypeToField[check.checkType]; - if (field) { - checkFields[field] = check.passed; - } - checkDetails[check.checkType] = { - ...check.details, - passed: check.passed, - checkedAt: check.checkedAt, - }; - } - - const isCompliant = - checkFields.diskEncryptionEnabled && - checkFields.antivirusEnabled && - checkFields.passwordPolicySet && - checkFields.screenLockEnabled; - - const updatedDevice = await db.device.update({ - where: { id: deviceId }, - data: { - ...checkFields, - checkDetails: checkDetails as Prisma.InputJsonValue, - isCompliant, - lastCheckIn: new Date(), - ...(agentVersion ? { agentVersion } : {}), - }, - select: { isCompliant: true }, - }); - - return NextResponse.json({ - isCompliant: updatedDevice.isCompliant, - nextCheckIn: new Date(Date.now() + 60 * 60 * 1000).toISOString(), - }); - } catch (error) { - console.error('Error processing device check-in:', error); - return NextResponse.json({ error: 'Failed to process check-in' }, { status: 500 }); - } +export async function POST(req: Request) { + return proxyToApi(req, '/v1/device-agent/check-in', 'POST'); } diff --git a/apps/portal/src/app/api/device-agent/exchange-code/route.ts b/apps/portal/src/app/api/device-agent/exchange-code/route.ts index 583e151f04..5d17ddce26 100644 --- a/apps/portal/src/app/api/device-agent/exchange-code/route.ts +++ b/apps/portal/src/app/api/device-agent/exchange-code/route.ts @@ -1,57 +1,8 @@ -import { client as kv } from '@comp/kv'; -import { NextResponse } from 'next/server'; -import { z } from 'zod'; +import { proxyToApi } from '../proxy'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const exchangeCodeSchema = z.object({ - code: z.string().min(1), -}); - -interface StoredAuthCode { - sessionToken: string; - userId: string; - state: string; - createdAt: number; -} - -/** - * Exchanges a single-use authorization code for a session token. - * No authentication required — the code itself is the proof of auth. - * This follows the same pattern as OAuth authorization code exchange. - */ export async function POST(req: Request) { - try { - const body = await req.json(); - const parsed = exchangeCodeSchema.safeParse(body); - - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request body', details: parsed.error.flatten() }, - { status: 400 }, - ); - } - - const { code } = parsed.data; - const kvKey = `device-auth:${code}`; - - // Atomic get+delete to prevent race condition (TOCTOU) - const stored = await kv.getdel(kvKey); - - if (!stored) { - return NextResponse.json( - { error: 'Invalid or expired authorization code' }, - { status: 401 }, - ); - } - - return NextResponse.json({ - session_token: stored.sessionToken, - user_id: stored.userId, - }); - } catch (error) { - console.error('Error exchanging device auth code:', error); - return NextResponse.json({ error: 'Failed to exchange auth code' }, { status: 500 }); - } + return proxyToApi(req, '/v1/device-agent/exchange-code', 'POST'); } diff --git a/apps/portal/src/app/api/device-agent/my-organizations/route.ts b/apps/portal/src/app/api/device-agent/my-organizations/route.ts index 6f78675d93..ee1a3afa4f 100644 --- a/apps/portal/src/app/api/device-agent/my-organizations/route.ts +++ b/apps/portal/src/app/api/device-agent/my-organizations/route.ts @@ -1,48 +1,8 @@ -import { db } from '@db'; -import { type NextRequest, NextResponse } from 'next/server'; -import { getDeviceAgentSession } from '../session'; +import { proxyToApi } from '../proxy'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -/** - * Returns all organizations the authenticated user belongs to. - * Used by the device agent to register the device for all orgs. - */ -export async function GET(req: NextRequest) { - try { - const session = await getDeviceAgentSession(req); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const memberships = await db.member.findMany({ - where: { - userId: session.user.id, - deactivated: false, - }, - include: { - organization: { - select: { - id: true, - name: true, - slug: true, - }, - }, - }, - }); - - const organizations = memberships.map((m) => ({ - organizationId: m.organization.id, - organizationName: m.organization.name, - organizationSlug: m.organization.slug, - role: m.role, - })); - - return NextResponse.json({ organizations }); - } catch (error) { - console.error('Error fetching user organizations:', error); - return NextResponse.json({ error: 'Failed to fetch organizations' }, { status: 500 }); - } +export async function GET(req: Request) { + return proxyToApi(req, '/v1/device-agent/my-organizations', 'GET'); } diff --git a/apps/portal/src/app/api/device-agent/proxy.ts b/apps/portal/src/app/api/device-agent/proxy.ts new file mode 100644 index 0000000000..3c31130dfb --- /dev/null +++ b/apps/portal/src/app/api/device-agent/proxy.ts @@ -0,0 +1,66 @@ +import { env } from '@/env.mjs'; +import { NextResponse } from 'next/server'; + +const API_BASE = env.BACKEND_API_URL || env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; + +/** + * Thin proxy that forwards device-agent requests to the NestJS API. + * + * Existing installed agents still call portal routes with Bearer tokens. + * This proxy forwards them to the API where HybridAuthGuard handles auth. + * + * TODO: Delete after 2-3 device agent release cycles once all agents + * have auto-updated to call the API directly. + */ +export async function proxyToApi( + req: Request, + apiPath: string, + method: 'GET' | 'POST' | 'HEAD' = 'GET', +): Promise { + const url = `${API_BASE}${apiPath}`; + + const headers: Record = {}; + + if (method === 'POST') { + headers['Content-Type'] = 'application/json'; + } + + // Forward auth headers + const authorization = req.headers.get('authorization'); + if (authorization) { + headers['Authorization'] = authorization; + } + + // Forward cookies for browser-based requests + const cookie = req.headers.get('cookie'); + if (cookie) { + headers['Cookie'] = cookie; + } + + const fetchOptions: RequestInit = { method, headers }; + + if (method === 'POST') { + try { + fetchOptions.body = await req.text(); + } catch { + // No body + } + } + + const response = await fetch(url, fetchOptions); + + // Forward the response directly (preserves streaming for binary files) + const responseHeaders: Record = {}; + const contentType = response.headers.get('Content-Type'); + const contentLength = response.headers.get('Content-Length'); + const cacheControl = response.headers.get('Cache-Control'); + + if (contentType) responseHeaders['Content-Type'] = contentType; + if (contentLength) responseHeaders['Content-Length'] = contentLength; + if (cacheControl) responseHeaders['Cache-Control'] = cacheControl; + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders, + }); +} diff --git a/apps/portal/src/app/api/device-agent/register/route.ts b/apps/portal/src/app/api/device-agent/register/route.ts index 0ca35773ac..e49b2db68e 100644 --- a/apps/portal/src/app/api/device-agent/register/route.ts +++ b/apps/portal/src/app/api/device-agent/register/route.ts @@ -1,186 +1,8 @@ -import { db } from '@db'; -import { randomUUID } from 'node:crypto'; -import { type NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; -import { getDeviceAgentSession } from '../session'; +import { proxyToApi } from '../proxy'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const registerDeviceSchema = z.object({ - name: z.string().min(1), - hostname: z.string().min(1), - platform: z.enum(['macos', 'windows', 'linux']), - osVersion: z.string().min(1), - serialNumber: z.string().optional(), - hardwareModel: z.string().optional(), - agentVersion: z.string().optional(), - organizationId: z.string().min(1), -}); - -export async function POST(req: NextRequest) { - try { - const session = await getDeviceAgentSession(req); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = await req.json(); - const parsed = registerDeviceSchema.safeParse(body); - - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request body', details: parsed.error.flatten() }, - { status: 400 }, - ); - } - - const { name, hostname, platform, osVersion, serialNumber, hardwareModel, agentVersion, organizationId } = - parsed.data; - - // Verify the user is a member of the organization - const member = await db.member.findFirst({ - where: { - userId: session.user.id, - organizationId, - deactivated: false, - }, - }); - - if (!member) { - return NextResponse.json({ error: 'Not a member of this organization' }, { status: 403 }); - } - - // Branch on serialNumber to avoid collisions for serial-less devices. - // PostgreSQL treats NULLs as distinct in unique constraints, so devices - // without a serial number can safely coexist in the same org. - let device; - - if (serialNumber) { - // Check if a device with this serial number already exists in the org - const existing = await db.device.findUnique({ - where: { - serialNumber_organizationId: { - serialNumber, - organizationId, - }, - }, - select: { id: true, memberId: true }, - }); - - if (existing && existing.memberId !== member.id) { - // Serial number belongs to a different member. This happens when multiple - // machines report the same generic serial (e.g. "System Serial Number", - // "To Be Filled By O.E.M."). Instead of blocking, treat the serial as - // unreliable and register with a generated fallback serial. - // Format: "fallback::" — self-documenting and unique. - const fallback = await db.device.findFirst({ - where: { - hostname, - memberId: member.id, - organizationId, - serialNumber: { startsWith: `fallback:${serialNumber}:` }, - }, - }); - - if (fallback) { - device = await db.device.update({ - where: { id: fallback.id }, - data: { name, platform, osVersion, hardwareModel, agentVersion }, - }); - } else { - const fallbackSerial = `fallback:${serialNumber}:${randomUUID()}`; - device = await db.device.create({ - data: { - name, - hostname, - platform, - osVersion, - serialNumber: fallbackSerial, - hardwareModel, - agentVersion, - memberId: member.id, - organizationId, - }, - }); - } - - return NextResponse.json({ deviceId: device.id }); - } - - if (existing) { - // Same member re-registering their own device — update it - device = await db.device.update({ - where: { id: existing.id }, - data: { - name, - hostname, - platform, - osVersion, - hardwareModel, - agentVersion, - }, - }); - } else { - // New device — create it - device = await db.device.create({ - data: { - name, - hostname, - platform, - osVersion, - serialNumber, - hardwareModel, - agentVersion, - memberId: member.id, - organizationId, - }, - }); - } - } else { - // No serial number — find by hostname + member + org (same user re-registering - // the same machine), or create a new record with serialNumber = null. - const existing = await db.device.findFirst({ - where: { - hostname, - memberId: member.id, - organizationId, - serialNumber: null, - }, - }); - - if (existing) { - device = await db.device.update({ - where: { id: existing.id }, - data: { - name, - platform, - osVersion, - hardwareModel, - agentVersion, - }, - }); - } else { - device = await db.device.create({ - data: { - name, - hostname, - platform, - osVersion, - serialNumber: null, - hardwareModel, - agentVersion, - memberId: member.id, - organizationId, - }, - }); - } - } - - return NextResponse.json({ deviceId: device.id }); - } catch (error) { - console.error('Error registering device:', error); - return NextResponse.json({ error: 'Failed to register device' }, { status: 500 }); - } +export async function POST(req: Request) { + return proxyToApi(req, '/v1/device-agent/register', 'POST'); } diff --git a/apps/portal/src/app/api/device-agent/session.ts b/apps/portal/src/app/api/device-agent/session.ts deleted file mode 100644 index 51ffcfab4b..0000000000 --- a/apps/portal/src/app/api/device-agent/session.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { auth } from '@/app/lib/auth'; -import { db } from '@db'; -import type { NextRequest } from 'next/server'; - -interface DeviceAgentSession { - user: { id: string; email: string; name: string }; -} - -/** - * Resolves the authenticated user for device-agent endpoints. - * - * Supports two auth methods: - * 1. Bearer token (device agent sends raw session token) - * — looked up directly in the DB, bypassing better-auth - * 2. Cookie-based session (browser requests) - * — delegated to better-auth via the API proxy - */ -export async function getDeviceAgentSession( - req: NextRequest, -): Promise { - const authHeader = req.headers.get('authorization'); - - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.slice(7); - return resolveSessionFromToken(token); - } - - const session = await auth.api.getSession({ headers: req.headers }); - if (!session?.user) return null; - - return { - user: { - id: session.user.id, - email: session.user.email, - name: session.user.name, - }, - }; -} - -async function resolveSessionFromToken( - token: string, -): Promise { - const session = await db.session.findUnique({ - where: { token }, - select: { - expiresAt: true, - user: { select: { id: true, email: true, name: true } }, - }, - }); - - if (!session || session.expiresAt < new Date()) { - return null; - } - - return { user: session.user }; -} diff --git a/apps/portal/src/app/api/device-agent/status/route.ts b/apps/portal/src/app/api/device-agent/status/route.ts index 2d42153648..82adda4da9 100644 --- a/apps/portal/src/app/api/device-agent/status/route.ts +++ b/apps/portal/src/app/api/device-agent/status/route.ts @@ -1,55 +1,19 @@ -import { db } from '@db'; -import { type NextRequest, NextResponse } from 'next/server'; -import { getDeviceAgentSession } from '../session'; +import type { NextRequest } from 'next/server'; +import { proxyToApi } from '../proxy'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export async function GET(req: NextRequest) { - try { - const session = await getDeviceAgentSession(req); + const deviceId = req.nextUrl.searchParams.get('deviceId'); + const organizationId = req.nextUrl.searchParams.get('organizationId'); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const params = new URLSearchParams(); + if (deviceId) params.set('deviceId', deviceId); + if (organizationId) params.set('organizationId', organizationId); - const deviceId = req.nextUrl.searchParams.get('deviceId'); - const organizationId = req.nextUrl.searchParams.get('organizationId'); + const query = params.toString(); + const path = `/v1/device-agent/status${query ? `?${query}` : ''}`; - if (!deviceId) { - // Return all devices for this user, optionally filtered by org - const devices = await db.device.findMany({ - where: { - member: { - userId: session.user.id, - deactivated: false, - }, - ...(organizationId ? { organizationId } : {}), - }, - orderBy: { installedAt: 'desc' }, - }); - - return NextResponse.json({ devices }); - } - - // Return a specific device - const device = await db.device.findFirst({ - where: { - id: deviceId, - member: { - userId: session.user.id, - deactivated: false, - }, - }, - }); - - if (!device) { - return NextResponse.json({ error: 'Device not found' }, { status: 404 }); - } - - return NextResponse.json({ device }); - } catch (error) { - console.error('Error fetching device status:', error); - return NextResponse.json({ error: 'Failed to fetch device status' }, { status: 500 }); - } + return proxyToApi(req, path, 'GET'); } diff --git a/apps/portal/src/app/api/device-agent/updates/[filename]/route.ts b/apps/portal/src/app/api/device-agent/updates/[filename]/route.ts index 7cb3fe88c9..6b635b0f12 100644 --- a/apps/portal/src/app/api/device-agent/updates/[filename]/route.ts +++ b/apps/portal/src/app/api/device-agent/updates/[filename]/route.ts @@ -1,134 +1,21 @@ -import { s3Client } from '@/utils/s3'; -import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; -import { type NextRequest, NextResponse } from 'next/server'; -import { Readable } from 'stream'; +import type { NextRequest } from 'next/server'; +import { proxyToApi } from '../../proxy'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export const maxDuration = 300; - -/** Environment subfolder: 'staging' or 'production' (default) */ -const S3_ENV = process.env.DEVICE_AGENT_S3_ENV || 'production'; -const S3_PREFIX = `device-agent/${S3_ENV}/updates`; - -/** Allowed file extensions for auto-update files */ -const ALLOWED_EXTENSIONS = new Set([ - '.yml', - '.zip', - '.exe', - '.blockmap', - '.AppImage', - '.dmg', -]); - -const CONTENT_TYPES: Record = { - '.yml': 'text/yaml', - '.zip': 'application/zip', - '.exe': 'application/octet-stream', - '.blockmap': 'application/octet-stream', - '.AppImage': 'application/octet-stream', - '.dmg': 'application/x-apple-diskimage', -}; - -function getExtension(filename: string): string { - // Handle .AppImage specially (not a dotted extension from lastIndexOf perspective) - if (filename.endsWith('.AppImage')) return '.AppImage'; - const dotIndex = filename.lastIndexOf('.'); - return dotIndex >= 0 ? filename.slice(dotIndex) : ''; -} - -function isValidFilename(filename: string): boolean { - // Block path traversal - if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { - return false; - } - const ext = getExtension(filename); - return ALLOWED_EXTENSIONS.has(ext); -} export async function GET( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ filename: string }> }, ) { const { filename } = await params; - - if (!isValidFilename(filename)) { - return new NextResponse('Not found', { status: 404 }); - } - - const bucketName = process.env.FLEET_AGENT_BUCKET_NAME; - if (!bucketName) { - return new NextResponse('Server configuration error', { status: 500 }); - } - - const key = `${S3_PREFIX}/${filename}`; - const ext = getExtension(filename); - const contentType = CONTENT_TYPES[ext] || 'application/octet-stream'; - - try { - const command = new GetObjectCommand({ Bucket: bucketName, Key: key }); - const s3Response = await s3Client.send(command); - - if (!s3Response.Body) { - return new NextResponse('Not found', { status: 404 }); - } - - const headers: Record = { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=300', - }; - - if (typeof s3Response.ContentLength === 'number') { - headers['Content-Length'] = s3Response.ContentLength.toString(); - } - - const s3Stream = s3Response.Body as Readable; - const webStream = Readable.toWeb(s3Stream) as unknown as ReadableStream; - - return new NextResponse(webStream, { headers }); - } catch (error: unknown) { - if (error && typeof error === 'object' && 'name' in error && error.name === 'NoSuchKey') { - return new NextResponse('Not found', { status: 404 }); - } - console.error('Error serving update file:', { key, error }); - return new NextResponse('Internal server error', { status: 500 }); - } + return proxyToApi(req, `/v1/device-agent/updates/${encodeURIComponent(filename)}`, 'GET'); } export async function HEAD( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ filename: string }> }, ) { const { filename } = await params; - - if (!isValidFilename(filename)) { - return new NextResponse(null, { status: 404 }); - } - - const bucketName = process.env.FLEET_AGENT_BUCKET_NAME; - if (!bucketName) { - return new NextResponse(null, { status: 500 }); - } - - const key = `${S3_PREFIX}/${filename}`; - const ext = getExtension(filename); - const contentType = CONTENT_TYPES[ext] || 'application/octet-stream'; - - try { - const command = new HeadObjectCommand({ Bucket: bucketName, Key: key }); - const s3Response = await s3Client.send(command); - - const headers: Record = { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=300', - }; - - if (typeof s3Response.ContentLength === 'number') { - headers['Content-Length'] = s3Response.ContentLength.toString(); - } - - return new NextResponse(null, { headers }); - } catch { - return new NextResponse(null, { status: 404 }); - } + return proxyToApi(req, `/v1/device-agent/updates/${encodeURIComponent(filename)}`, 'HEAD'); } diff --git a/packages/device-agent/electron.vite.config.ts b/packages/device-agent/electron.vite.config.ts index 37c497d614..2f6c62d5cd 100644 --- a/packages/device-agent/electron.vite.config.ts +++ b/packages/device-agent/electron.vite.config.ts @@ -14,6 +14,9 @@ export default defineConfig({ __PORTAL_URL__: JSON.stringify( process.env.PORTAL_URL || 'https://portal.trycomp.ai', ), + __API_URL__: JSON.stringify( + process.env.API_URL || 'https://api.trycomp.ai', + ), __AGENT_VERSION__: JSON.stringify( process.env.AGENT_VERSION || pkg.version, ), diff --git a/packages/device-agent/src/main/auth.ts b/packages/device-agent/src/main/auth.ts index b0eae821cd..4807954e18 100644 --- a/packages/device-agent/src/main/auth.ts +++ b/packages/device-agent/src/main/auth.ts @@ -10,7 +10,7 @@ import type { StoredAuth, } from '../shared/types'; import { log } from './logger'; -import { clearAuth, getPortalUrl, setAuth } from './store'; +import { clearAuth, getApiUrl, getPortalUrl, setAuth } from './store'; /** How long to wait for the user to complete login in the browser */ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes @@ -21,6 +21,7 @@ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes */ export async function performLogin(deviceInfo: DeviceInfo): Promise { const portalUrl = getPortalUrl(); + const apiUrl = getApiUrl(); const state = randomBytes(16).toString('hex'); let server: Server | null = null; @@ -46,8 +47,8 @@ export async function performLogin(deviceInfo: DeviceInfo): Promise { @@ -23,7 +23,7 @@ export async function reportCheckResults(checks: CheckResult[]): Promise({ name: 'comp-device-agent', @@ -21,19 +24,23 @@ const store = new Store({ defaults: { auth: null, portalUrl: defaultPortalUrl, + apiUrl: defaultApiUrl, lastCheckResults: [], checkIntervalMs: 60 * 60 * 1000, // 1 hour openAtLogin: true, }, }); -// Always sync the portal URL with the current environment so dev +// Always sync URLs with the current environment so dev // doesn't accidentally keep a cached production URL (or vice-versa). if (store.get('portalUrl') !== defaultPortalUrl) { store.set('portalUrl', defaultPortalUrl); // Clear auth too since the session token is for the old portal store.set('auth', null); } +if (store.get('apiUrl') !== defaultApiUrl) { + store.set('apiUrl', defaultApiUrl); +} export function getAuth(): StoredAuth | null { return store.get('auth'); @@ -56,6 +63,14 @@ export function setPortalUrl(url: string): void { store.set('portalUrl', url); } +export function getApiUrl(): string { + return store.get('apiUrl'); +} + +export function setApiUrl(url: string): void { + store.set('apiUrl', url); +} + export function getLastCheckResults(): CheckResult[] { return store.get('lastCheckResults'); } diff --git a/packages/device-agent/src/shared/constants.ts b/packages/device-agent/src/shared/constants.ts index 8f7feec6d3..4fe3a957ee 100644 --- a/packages/device-agent/src/shared/constants.ts +++ b/packages/device-agent/src/shared/constants.ts @@ -1,10 +1,15 @@ declare const __PORTAL_URL__: string; +declare const __API_URL__: string; declare const __AGENT_VERSION__: string; /** Default portal base URL - injected at build time via electron-vite define */ export const DEFAULT_PORTAL_URL = typeof __PORTAL_URL__ !== 'undefined' ? __PORTAL_URL__ : 'https://app.staging.trycomp.ai'; +/** Default API base URL - injected at build time via electron-vite define */ +export const DEFAULT_API_URL = + typeof __API_URL__ !== 'undefined' ? __API_URL__ : 'https://api.staging.trycomp.ai'; + /** How often to run compliance checks (in milliseconds) */ export const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour @@ -12,13 +17,13 @@ export const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour export const AGENT_VERSION = typeof __AGENT_VERSION__ !== 'undefined' ? __AGENT_VERSION__ : '1.0.0'; -/** API route paths on the portal */ +/** API route paths on the NestJS API */ export const API_ROUTES = { - REGISTER: '/api/device-agent/register', - CHECK_IN: '/api/device-agent/check-in', - STATUS: '/api/device-agent/status', - MY_ORGANIZATIONS: '/api/device-agent/my-organizations', - EXCHANGE_CODE: '/api/device-agent/exchange-code', + REGISTER: '/v1/device-agent/register', + CHECK_IN: '/v1/device-agent/check-in', + STATUS: '/v1/device-agent/status', + MY_ORGANIZATIONS: '/v1/device-agent/my-organizations', + EXCHANGE_CODE: '/v1/device-agent/exchange-code', } as const; /** electron-store encryption key identifier */ diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 47c5598ea9..10278747bb 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -7895,6 +7895,191 @@ ] } }, + "/v1/device-agent/exchange-code": { + "post": { + "operationId": "DeviceAgentController_exchangeCode_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExchangeCodeDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Device Agent" + ] + } + }, + "/v1/device-agent/updates/{filename}": { + "get": { + "operationId": "DeviceAgentController_getUpdateFile_v1", + "parameters": [ + { + "name": "filename", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Device Agent" + ] + }, + "head": { + "operationId": "DeviceAgentController_headUpdateFile_v1", + "parameters": [ + { + "name": "filename", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Device Agent" + ] + } + }, + "/v1/device-agent/auth-code": { + "post": { + "operationId": "DeviceAgentController_generateAuthCode_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthCodeDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Device Agent" + ] + } + }, + "/v1/device-agent/my-organizations": { + "get": { + "operationId": "DeviceAgentController_getMyOrganizations_v1", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Device Agent" + ] + } + }, + "/v1/device-agent/register": { + "post": { + "operationId": "DeviceAgentController_registerDevice_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterDeviceDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Device Agent" + ] + } + }, + "/v1/device-agent/check-in": { + "post": { + "operationId": "DeviceAgentController_checkIn_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckInDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Device Agent" + ] + } + }, + "/v1/device-agent/status": { + "get": { + "operationId": "DeviceAgentController_getDeviceStatus_v1", + "parameters": [ + { + "name": "deviceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "organizationId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Device Agent" + ] + } + }, "/v1/device-agent/mac": { "get": { "description": "Downloads the Comp AI Device Agent installer for macOS as a DMG file. The agent helps monitor device compliance and security policies. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).", @@ -21094,6 +21279,22 @@ "instructions" ] }, + "ExchangeCodeDto": { + "type": "object", + "properties": {} + }, + "AuthCodeDto": { + "type": "object", + "properties": {} + }, + "RegisterDeviceDto": { + "type": "object", + "properties": {} + }, + "CheckInDto": { + "type": "object", + "properties": {} + }, "TaskResponseDto": { "type": "object", "properties": { From 5c35bf16428ed18a7366ed117c8af31a9737696b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:33:19 -0400 Subject: [PATCH 2/5] fix(workflow): streamline CodeSignTool extraction process in device-agent-release.yml (#2283) - Simplified the extraction logic for CodeSignTool by directly searching for the jar file within the extracted directory, eliminating the need for an intermediate directory variable. - Enhanced error handling to ensure the jar file is found after extraction, improving the reliability of the workflow. Co-authored-by: Mariano Fuentes --- .github/workflows/device-agent-release.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/device-agent-release.yml b/.github/workflows/device-agent-release.yml index aa5d8a09ce..fffd67410d 100644 --- a/.github/workflows/device-agent-release.yml +++ b/.github/workflows/device-agent-release.yml @@ -208,13 +208,10 @@ jobs: Invoke-WebRequest -Uri "https://github.com/SSLcom/CodeSignTool/releases/download/v1.3.0/CodeSignTool-v1.3.0-windows.zip" -OutFile "codesigntool.zip" Expand-Archive -Path "codesigntool.zip" -DestinationPath "codesigntool" - $cstDir = Get-ChildItem -Path "codesigntool" -Directory | Select-Object -First 1 - if (-not $cstDir) { throw "CodeSignTool directory not found after extraction" } - Write-Host "CodeSignTool directory: $($cstDir.FullName)" - - $jar = Get-ChildItem -Path $cstDir.FullName -Recurse -Filter "code_sign_tool-*.jar" | Select-Object -First 1 + $jar = Get-ChildItem -Path "codesigntool" -Recurse -Filter "code_sign_tool-*.jar" | Select-Object -First 1 if (-not $jar) { throw "CodeSignTool jar not found" } Write-Host "Found CodeSignTool jar at: $($jar.FullName)" + $cstDir = $jar.Directory $releaseDir = Get-Location Get-ChildItem -Path $releaseDir -Filter "*.exe" | ForEach-Object { From a32e00c7972966eef220ee92848886d7b818a414 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 11 Mar 2026 14:37:27 -0400 Subject: [PATCH 3/5] Mariano/device agent 3 (#2284) * fix(workflow): streamline CodeSignTool extraction process in device-agent-release.yml - Simplified the extraction logic for CodeSignTool by directly searching for the jar file within the extracted directory, eliminating the need for an intermediate directory variable. - Enhanced error handling to ensure the jar file is found after extraction, improving the reliability of the workflow. * chore(workflow): add workflow_dispatch trigger to device-agent-release.yml - Introduced a workflow_dispatch event to allow manual triggering of the device-agent-release workflow, enhancing flexibility in deployment processes. --- .github/workflows/device-agent-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/device-agent-release.yml b/.github/workflows/device-agent-release.yml index fffd67410d..c4fb596b9c 100644 --- a/.github/workflows/device-agent-release.yml +++ b/.github/workflows/device-agent-release.yml @@ -1,6 +1,7 @@ name: Device Agent Release on: + workflow_dispatch: push: branches: ['**'] paths: From 2c2b0a2ab62792a729cee8615f87838be23a3518 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:58:10 -0400 Subject: [PATCH 4/5] [dev] [Marfuen] mariano/device-agent-4 (#2285) * fix(workflow): streamline CodeSignTool extraction process in device-agent-release.yml - Simplified the extraction logic for CodeSignTool by directly searching for the jar file within the extracted directory, eliminating the need for an intermediate directory variable. - Enhanced error handling to ensure the jar file is found after extraction, improving the reliability of the workflow. * chore(workflow): add workflow_dispatch trigger to device-agent-release.yml - Introduced a workflow_dispatch event to allow manual triggering of the device-agent-release workflow, enhancing flexibility in deployment processes. * fix(workflow): correct error message formatting in device-agent-release.yml - Updated the error message for signature verification failure to use a single hyphen instead of a double hyphen, improving consistency in output formatting. --------- Co-authored-by: Mariano Fuentes --- .github/workflows/device-agent-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/device-agent-release.yml b/.github/workflows/device-agent-release.yml index c4fb596b9c..75dba11c45 100644 --- a/.github/workflows/device-agent-release.yml +++ b/.github/workflows/device-agent-release.yml @@ -244,7 +244,7 @@ jobs: Write-Host " Issuer: $($sig.SignerCertificate.Issuer)" Write-Host " Valid from: $($sig.SignerCertificate.NotBefore) to $($sig.SignerCertificate.NotAfter)" if ($sig.Status -ne 'Valid') { - Write-Host "::error::Signature verification FAILED for $($_.Name) — Status: $($sig.Status)" + Write-Host "::error::Signature verification FAILED for $($_.Name) - Status: $($sig.Status)" $allSigned = $false } } From 5215b77f8a0f548e4375a5291e08beb700d723af Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 11 Mar 2026 15:31:04 -0400 Subject: [PATCH 5/5] Mariano/device agent 5 (#2286) * fix(workflow): streamline CodeSignTool extraction process in device-agent-release.yml - Simplified the extraction logic for CodeSignTool by directly searching for the jar file within the extracted directory, eliminating the need for an intermediate directory variable. - Enhanced error handling to ensure the jar file is found after extraction, improving the reliability of the workflow. * chore(workflow): add workflow_dispatch trigger to device-agent-release.yml - Introduced a workflow_dispatch event to allow manual triggering of the device-agent-release workflow, enhancing flexibility in deployment processes. * fix(workflow): correct error message formatting in device-agent-release.yml - Updated the error message for signature verification failure to use a single hyphen instead of a double hyphen, improving consistency in output formatting. * fix(workflow): update CodeSignTool directory extraction in device-agent-release.yml - Modified the directory extraction logic for CodeSignTool to reference the parent directory, improving the accuracy of the path used in the workflow. --------- Signed-off-by: Mariano Fuentes --- .github/workflows/device-agent-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/device-agent-release.yml b/.github/workflows/device-agent-release.yml index 75dba11c45..2d63a20a38 100644 --- a/.github/workflows/device-agent-release.yml +++ b/.github/workflows/device-agent-release.yml @@ -212,7 +212,7 @@ jobs: $jar = Get-ChildItem -Path "codesigntool" -Recurse -Filter "code_sign_tool-*.jar" | Select-Object -First 1 if (-not $jar) { throw "CodeSignTool jar not found" } Write-Host "Found CodeSignTool jar at: $($jar.FullName)" - $cstDir = $jar.Directory + $cstDir = $jar.Directory.Parent $releaseDir = Get-Location Get-ChildItem -Path $releaseDir -Filter "*.exe" | ForEach-Object {