From c7b30a4fb161a84adcb4f988af4b4a9275d22d6f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 30 Jun 2026 14:18:43 -0400 Subject: [PATCH 01/18] =?UTF-8?q?refactor(self-heal):=20comp=20does=20no?= =?UTF-8?q?=20classification=20=E2=80=94=20every=20dynamic=20failure=20is?= =?UTF-8?q?=20pending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New model: comp is pure plumbing. A dynamic check that doesn't succeed — a real finding, a customer/transport error, or a thrown execution error — is held as 'inconclusive' ("pending", hidden from the customer) and handed to the self-heal agent, which is the ONLY thing that decides our-bug (fix) vs real fail (show). - decideRunStatus: dynamic + non-success → 'inconclusive'; no error-code logic. - splitFailuresByDisposition: returns all-held (nothing decided on the comp side). - The error-code classifier is now unreferenced (to be deleted in cleanup). WIP: reveal-real-fail endpoint + the agent decision rewrite + spec updates still to come. Not deployed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014PAsijjUQ1bMJuw8NuC1oR --- .../utils/task-check-evaluation.ts | 52 ++++++------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/apps/api/src/integration-platform/utils/task-check-evaluation.ts b/apps/api/src/integration-platform/utils/task-check-evaluation.ts index b593418ef..090f46638 100644 --- a/apps/api/src/integration-platform/utils/task-check-evaluation.ts +++ b/apps/api/src/integration-platform/utils/task-check-evaluation.ts @@ -1,5 +1,4 @@ import { ActiveExceptionSet } from '../../cloud-security/finding-exceptions'; -import { classifyCheckFailure } from '../services/check-failure-classifier'; import { redactSecrets } from './redact-secrets'; /** A failing finding, identified the same way an exception is keyed. */ @@ -88,25 +87,14 @@ export interface FailureDisposition { */ export function splitFailuresByDisposition( failing: ClassifiableFailure[], - fleet?: { passing: number; failing: number } | null, + _fleet?: { passing: number; failing: number } | null, ): FailureDisposition { - const effective: ClassifiableFailure[] = []; - const held: ClassifiableFailure[] = []; - for (const f of failing) { - const { class: cls } = classifyCheckFailure({ - httpStatus: f.httpStatus, - errorText: f.errorText, - threw: f.threw, - fleet, - }); - if (cls === 'our_side' || cls === 'transient') { - held.push(f); - } else { - // 'compliance' + 'customer_side' → show the customer. - effective.push(f); - } - } - return { effective, held }; + // New model: comp does NO classification. EVERY dynamic failure is HELD as + // 'inconclusive' ("pending", hidden from the customer) and handed to the + // self-heal agent — the ONLY thing that decides our-bug (fix) vs real fail + // (show). So nothing is "effective" here; the agent reveals genuine fails via + // the internal API. (The caller gates this to dynamic integrations only.) + return { effective: [], held: failing }; } /** @@ -122,24 +110,18 @@ export function splitFailuresByDisposition( */ export function decideRunStatus(params: { resultStatus: string; - failures: ClassifiableFailure[]; + // Accepted for caller compatibility; no longer used — comp does not classify. + failures?: ClassifiableFailure[]; isDynamic: boolean; - fleet?: { passing: number; failing: number } | null; }): 'success' | 'failed' | 'inconclusive' { - const { resultStatus, failures, isDynamic, fleet } = params; - let runStatus: 'success' | 'failed' | 'inconclusive' = - resultStatus === 'success' ? 'success' : 'failed'; - if (isDynamic) { - if (resultStatus === 'error') { - runStatus = 'inconclusive'; - } else if ( - failures.length > 0 && - splitFailuresByDisposition(failures, fleet).effective.length === 0 - ) { - runStatus = 'inconclusive'; - } - } - return runStatus; + const { resultStatus, isDynamic } = params; + if (resultStatus === 'success') return 'success'; + // DYNAMIC: every non-success — a finding, a customer/transport error, OR a + // thrown execution error — is held as 'inconclusive' ("pending", hidden from + // the customer) and handed to the self-heal agent. The AGENT is the only thing + // that decides our-bug (fix) vs real fail (show); comp does no classification. + // Static/AWS/GCP/Azure keep the plain failed mapping (isDynamic = false). + return isDynamic ? 'inconclusive' : 'failed'; } /** From e2f1fc089013e50acb2bf9d67195eaa1aa98493d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 30 Jun 2026 14:40:22 -0400 Subject: [PATCH 02/18] =?UTF-8?q?feat(self-heal):=20add=20/reveal=20endpoi?= =?UTF-8?q?nt=20=E2=80=94=20persist=20the=20real=20fail=20(customer-side?= =?UTF-8?q?=20/=20finding)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent calls /reveal when it verdicts a held check as a GENUINE fail (the customer's creds/config are wrong, or a real compliance finding). Unlike /rerun (which applies the dynamic hold rule and may re-hold as 'inconclusive'), /reveal persists the TRUE status — success if it now passes, 'failed' with the real findings shown (failedCount > 0) otherwise — so the customer sees the red instead of a silent "pending". Mirrors rerunAndPersistCheck; never holds, never disables. tsc clean. Not deployed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014PAsijjUQ1bMJuw8NuC1oR --- .../internal-integration-debug.controller.ts | 17 ++++ .../internal-integration-debug.service.ts | 94 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts b/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts index c9ddb7ad6..43c48dd47 100644 --- a/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts +++ b/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts @@ -157,6 +157,23 @@ export class InternalIntegrationDebugController { }); } + /** + * Persist the REAL result for a check (success/failed — never held) — called by + * the self-heal agent when it verdicts a held check as a genuine fail (a + * customer-side error or a real compliance finding) so the customer sees the red. + */ + @Post('connections/:connectionId/reveal') + async revealConnectionCheck( + @Param('connectionId') connectionId: string, + @Body() body: RerunCheckBody, + ) { + return this.debugService.revealAndPersistCheck({ + connectionId, + checkId: body.checkId, + taskId: body.taskId, + }); + } + /** * Read recently captured OAuth callback errors (recorded by the frontend on a * failed connect). Use this to diagnose "the integration won't connect" for diff --git a/apps/api/src/integration-platform/services/internal-integration-debug.service.ts b/apps/api/src/integration-platform/services/internal-integration-debug.service.ts index 37efd8156..b914ae427 100644 --- a/apps/api/src/integration-platform/services/internal-integration-debug.service.ts +++ b/apps/api/src/integration-platform/services/internal-integration-debug.service.ts @@ -564,4 +564,98 @@ export class InternalIntegrationDebugService { ); return { status }; } + + /** + * Re-run ONE check and PERSIST the REAL result — used when the self-heal agent + * verdicts a held check as a GENUINE fail (the customer's creds/config are + * wrong, OR a real compliance finding). Unlike rerunAndPersistCheck (which + * applies the dynamic hold rule and may re-hold as 'inconclusive'), this writes + * the TRUE status: 'success' if it now passes, 'failed' (with the real findings + * shown, failedCount > 0) otherwise — so the customer sees the red instead of a + * silent "pending". It never holds and never disables. + */ + async revealAndPersistCheck(params: { + connectionId: string; + checkId: string; + taskId?: string | null; + }): Promise<{ status: 'success' | 'failed' }> { + const { connectionId, checkId, taskId } = params; + const connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { organizationId: true }, + }); + if (!connection) { + throw new NotFoundException(`Connection ${connectionId} not found`); + } + + const result = await this.runner.runChecks({ + connectionId, + organizationId: connection.organizationId, + checkId, + }); + const checkResult = result.results[0]; + if (!checkResult) { + throw new NotFoundException( + `Check ${checkId} produced no result for connection ${connectionId}`, + ); + } + + // The REAL status — never held. A genuine fail shows red to the customer. + const status: 'success' | 'failed' = + checkResult.status === 'success' ? 'success' : 'failed'; + + const checkRun = await this.checkRunRepository.create({ + connectionId, + taskId: taskId ?? undefined, + checkId, + checkName: checkResult.checkName, + }); + const resultsToStore = [ + ...checkResult.result.passingResults.map((r) => ({ + checkRunId: checkRun.id, + passed: true, + resourceType: r.resourceType, + resourceId: r.resourceId, + title: r.title, + description: r.description, + evidence: r.evidence + ? (JSON.parse(JSON.stringify(r.evidence)) as Prisma.InputJsonValue) + : undefined, + })), + ...checkResult.result.findings.map((f) => ({ + checkRunId: checkRun.id, + passed: false, + resourceType: f.resourceType, + resourceId: f.resourceId, + title: f.title, + description: f.description, + severity: f.severity, + remediation: f.remediation, + evidence: f.evidence as Prisma.InputJsonValue, + })), + ]; + if (resultsToStore.length > 0) { + await this.checkRunRepository.addResults(resultsToStore); + } + await this.checkRunRepository.complete(checkRun.id, { + status, + durationMs: checkResult.durationMs, + totalChecked: + checkResult.result.summary?.totalChecked ?? + checkResult.result.passingResults.length + + checkResult.result.findings.length, + passedCount: checkResult.result.passingResults.length, + // REAL fail → show the findings (NOT hidden like a held 'inconclusive' run). + failedCount: checkResult.result.findings.length, + errorMessage: checkResult.error, + logs: JSON.parse( + JSON.stringify(checkResult.result.logs), + ) as Prisma.InputJsonValue, + }); + + this.logger.log( + `Self-heal reveal: connection ${connectionId}, check ${checkId} -> ${status} (real result shown)`, + ); + return { status }; + } } From 5892ccf036211b41d82b5cb3d93aabe4ef09a89d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 30 Jun 2026 14:47:19 -0400 Subject: [PATCH 03/18] =?UTF-8?q?refactor(self-heal):=20delete=20ALL=20com?= =?UTF-8?q?p-side=20classification=20=E2=80=94=20the=20agent=20decides=20e?= =?UTF-8?q?verything?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the design: comp must do ZERO judging of customer-vs-us. Removed every bit of pre-classification so the only place that decides is the self-heal agent. - Deleted check-failure-classifier.ts (the error-code customer/our_side/finding judging) + its spec. - Removed failureSignalsFromEvidence (HTTP-status/error-text extraction), splitFailuresByDisposition, ClassifiableFailure, FailureDisposition from task-check-evaluation.ts. decideRunStatus is now just: success → success; dynamic non-success → 'inconclusive' (pending); else failed. - Run paths (scheduled + manual) and rerun/reveal now record findings by identity only ({connectionId, checkId, resourceId}); a dynamic failure is always held pending for the agent. No signals, no patterns, no guessing. - Rewrote the run-status spec for the new rule; deleted the split/signals specs. my changed files compile; task-check-evaluation tests pass (19). Pre-existing worktree spec failures (sync-gws / variables / credential-vault) are unrelated. Not deployed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014PAsijjUQ1bMJuw8NuC1oR --- .../task-integrations.controller.ts | 32 ++-- .../services/check-failure-classifier.spec.ts | 80 ---------- .../services/check-failure-classifier.ts | 138 ----------------- .../task-check-evaluation.runstatus.spec.ts | 105 ++----------- .../task-check-evaluation.signals.spec.ts | 82 ----------- .../utils/task-check-evaluation.split.spec.ts | 62 -------- .../utils/task-check-evaluation.ts | 139 ++---------------- .../run-task-integration-checks.ts | 25 ++-- 8 files changed, 54 insertions(+), 609 deletions(-) delete mode 100644 apps/api/src/integration-platform/services/check-failure-classifier.spec.ts delete mode 100644 apps/api/src/integration-platform/services/check-failure-classifier.ts delete mode 100644 apps/api/src/integration-platform/utils/task-check-evaluation.signals.spec.ts delete mode 100644 apps/api/src/integration-platform/utils/task-check-evaluation.split.spec.ts diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts index c3458f0ec..654258182 100644 --- a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts @@ -43,9 +43,7 @@ import { countEffectiveFailures, decideTaskStatus, decideRunStatus, - splitFailuresByDisposition, - failureSignalsFromEvidence, - type ClassifiableFailure, + type FailingFinding, } from '../utils/task-check-evaluation'; import { capEvidence, @@ -66,9 +64,9 @@ interface ConnectionCheckOutcome { status: 'success' | 'failed' | 'error'; findings: number; passing: number; - /** This account's failing findings (+ redacted error signals) for exception - * filtering and self-heal classification. */ - failures: ClassifiableFailure[]; + /** This account's failing findings (identity only) for exception filtering. + * comp does not classify — the self-heal agent decides our-bug vs real fail. */ + failures: FailingFinding[]; } interface TaskIntegrationCheck { @@ -411,7 +409,7 @@ export class TaskIntegrationsController { let lastCheckRunId: string | undefined; // Failing findings across all accounts (keyed like an exception) so task // status can exclude explicitly-excepted ones below. - const failingFindings: ClassifiableFailure[] = []; + const failingFindings: FailingFinding[] = []; // Sequential so each per-account run commits as it completes — a slow or // failing account still leaves the earlier accounts' results persisted. @@ -449,13 +447,10 @@ export class TaskIntegrationsController { // rule, via the shared helpers). Any real (non-excepted) finding → failed; // else any passing result → done; else leave unchanged. const exceptions = await loadActiveExceptionSet(organizationId); - // For DYNAMIC integrations, hold our-side/transient failures as inconclusive - // (same rule as the scheduled path): they must not fail the task. Static/AWS - // behavior is unchanged. Safe degradation: a finding with no readable error - // signal classifies as a real failure, exactly as today. - const statusFailures = isDynamic - ? splitFailuresByDisposition(failingFindings).effective - : failingFindings; + // For DYNAMIC integrations EVERY failure is held (pending) — comp never + // classifies; the self-heal agent decides our-bug vs real fail. So none fail + // the task here. Static/AWS behavior is unchanged (no holding). + const statusFailures = isDynamic ? [] : failingFindings; const heldCount = failingFindings.length - statusFailures.length; if (heldCount > 0) { this.logger.log( @@ -674,16 +669,13 @@ export class TaskIntegrationsController { connectionId, checkId: checkDef.id, resourceId: f.resourceId, - // Redacted error signals for self-heal classification (dynamic only). - ...failureSignalsFromEvidence(f.evidence, checkResult.status), })); - // Per-account run status (shared rule): a dynamic run that failed only for - // our-side/transient reasons is held as 'inconclusive' (customer never sees - // it; the agent fixes it). Static integrations keep success/failed. + // Per-account run status (shared rule): a DYNAMIC run that didn't succeed is + // held as 'inconclusive' (pending, hidden) and handed to the self-heal agent + // — comp never classifies. Static integrations keep success/failed. const runStatus = decideRunStatus({ resultStatus: checkResult.status, - failures, isDynamic, }); diff --git a/apps/api/src/integration-platform/services/check-failure-classifier.spec.ts b/apps/api/src/integration-platform/services/check-failure-classifier.spec.ts deleted file mode 100644 index 43915e4a9..000000000 --- a/apps/api/src/integration-platform/services/check-failure-classifier.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { classifyCheckFailure } from './check-failure-classifier'; - -describe('classifyCheckFailure', () => { - it('treats a runtime exception as our-side', () => { - const r = classifyCheckFailure({ threw: true, errorText: 'x is not a function' }); - expect(r.class).toBe('our_side'); - expect(r.customerActionable).toBe(false); - }); - - it('treats a failure with no execution signal as a real compliance finding', () => { - const r = classifyCheckFailure({}); - expect(r.class).toBe('compliance'); - }); - - it.each([500, 502, 503, 408, 429])('treats HTTP %i as transient', (status) => { - expect(classifyCheckFailure({ httpStatus: status }).class).toBe('transient'); - }); - - it.each([ - 'request timed out', - 'fetch failed', - 'ECONNRESET', - 'service unavailable', - ])('treats network text "%s" as transient', (errorText) => { - expect(classifyCheckFailure({ errorText }).class).toBe('transient'); - }); - - it.each([401, 403])('treats HTTP %i as customer-side + actionable', (status) => { - const r = classifyCheckFailure({ httpStatus: status }); - expect(r.class).toBe('customer_side'); - expect(r.customerActionable).toBe(true); - }); - - it.each([ - 'Invalid API key', - 'token has expired', - 'your key was revoked', - 'organization is not entitled for api access', - 'please upgrade your plan', - ])('treats creds/plan text "%s" as customer-side', (errorText) => { - expect(classifyCheckFailure({ errorText }).class).toBe('customer_side'); - }); - - it.each([ - 'not allowed to perform actions outside the project this key is scoped to', - 'org_id is required', - 'not allowed for organization API keys', - 'this endpoint is deprecated', - 'res.text is not a function', - ])('treats our-bug text "%s" as our-side', (errorText) => { - expect(classifyCheckFailure({ errorText }).class).toBe('our_side'); - }); - - it.each([404, 400, 405, 422])('treats HTTP %i as our-side', (status) => { - expect(classifyCheckFailure({ httpStatus: status }).class).toBe('our_side'); - }); - - it('never blames the customer for an ambiguous execution failure (defaults our-side)', () => { - const r = classifyCheckFailure({ errorText: 'something weird happened' }); - expect(r.class).toBe('our_side'); - expect(r.customerActionable).toBe(false); - }); - - it('treats a customer-looking error as our-side when the whole fleet is failing', () => { - const r = classifyCheckFailure({ - httpStatus: 403, - fleet: { passing: 0, failing: 5 }, - }); - expect(r.class).toBe('our_side'); - }); - - it('keeps a 401 as customer-side when other connections are passing', () => { - const r = classifyCheckFailure({ - httpStatus: 401, - fleet: { passing: 9, failing: 1 }, - }); - expect(r.class).toBe('customer_side'); - expect(r.customerActionable).toBe(true); - }); -}); diff --git a/apps/api/src/integration-platform/services/check-failure-classifier.ts b/apps/api/src/integration-platform/services/check-failure-classifier.ts deleted file mode 100644 index 3dd86b5fd..000000000 --- a/apps/api/src/integration-platform/services/check-failure-classifier.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Classifies WHY a dynamic-integration check failed, so the self-heal layer can - * decide what the customer sees: - * - * - 'compliance' → the check evaluated fine and returned a real finding - * (customer is genuinely non-compliant). SHOW as a fail. - * - 'transient' → timeout / 5xx / rate-limit. RETRY, show nothing. - * - 'customer_side' → their credentials / plan / config. Show "action needed". - * - 'our_side' → our bug / a vendor change we don't handle yet. HIDE the - * red, hand to the agent / open a ticket. - * - * Guiding rule (the product guarantee): NEVER blame the customer without positive - * proof. Any ambiguous EXECUTION failure defaults to 'our_side', and a customer- - * looking error that is actually failing fleet-wide is treated as 'our_side'. - * - * Pure function — no I/O, no secrets. `errorText` MUST be pre-redacted by the caller. - */ - -export type FailureClass = - | 'compliance' - | 'transient' - | 'customer_side' - | 'our_side'; - -export interface ClassifyInput { - /** HTTP status the failure carried, if any (e.g. 401, 404, 503). */ - httpStatus?: number | null; - /** Error message / response-body snippet. MUST already be redacted of secrets. */ - errorText?: string | null; - /** True if the runtime itself threw (a code error), not a vendor response. */ - threw?: boolean; - /** - * Optional fleet signal for the SAME check across the provider's other active - * connections. Used only to avoid blaming a single customer for a fleet-wide - * (i.e. our-side) breakage. - */ - fleet?: { passing: number; failing: number } | null; -} - -export interface ClassifyResult { - class: FailureClass; - reason: string; - /** True only for a confirmed customer_side result (safe to ask them to act). */ - customerActionable: boolean; -} - -const TRANSIENT_RE = - /(timeout|timed out|etimedout|econnreset|econnrefused|socket hang up|network error|fetch failed|eai_again|temporarily unavailable|service unavailable|try again)/i; - -const CUSTOMER_RE = - /(invalid api key|invalid token|invalid credentials|unauthorized|authentication failed|access denied|token (has )?expired|expired token|revoked|not entitled|api access is not|no access to the api|upgrade your plan|plan does ?n.?t include|plan does not include|insufficient (permission|scope|privileges))/i; - -const OUR_SIDE_RE = - /(scoped to|org_id is required|not allowed for organization|deprecated|no longer supported|is not a function|cannot read propert|undefined is not|unexpected (token|response|status|end of)|method not allowed|missing_header|malformed)/i; - -function make( - cls: FailureClass, - reason: string, - customerActionable = false, -): ClassifyResult { - return { class: cls, reason, customerActionable }; -} - -export function classifyCheckFailure(input: ClassifyInput): ClassifyResult { - const status = input.httpStatus ?? null; - const text = input.errorText ?? ''; - const fleet = input.fleet ?? null; - // "Failing for many, passing for none" = almost certainly our bug, not one - // customer's problem. - const fleetWide = !!fleet && fleet.failing >= 2 && fleet.passing === 0; - - // 1. A runtime exception in our own code is unambiguously our-side. - if (input.threw) { - return make('our_side', 'Runtime error while executing the check.'); - } - - const hasHttpError = status != null && status >= 400; - const hasErrorText = text.trim().length > 0; - - // 2. No execution-failure signal at all → the check evaluated and returned a - // genuine finding. Show it (this is the product working correctly). - if (!hasHttpError && !hasErrorText) { - return make('compliance', 'Check evaluated and returned a finding.'); - } - - // 3. Transient — retry, show nothing. - if ( - status === 408 || - status === 425 || - status === 429 || - (status != null && status >= 500) || - TRANSIENT_RE.test(text) - ) { - return make('transient', 'Transient network/availability error.'); - } - - // 4. Customer-side — requires a positive signal (hard 401/403 or an explicit - // creds/plan message). Even then, if the whole fleet is failing it is ours. - const customerSignal = status === 401 || status === 403 || CUSTOMER_RE.test(text); - if (customerSignal) { - if (fleetWide) { - return make( - 'our_side', - 'Auth/plan-looking error, but the check is failing fleet-wide → treat as our-side.', - ); - } - return make( - 'customer_side', - 'Authentication/authorization/plan issue specific to this connection.', - true, - ); - } - - // 5. Known our-side shapes: 4xx on endpoints we should handle, deprecated - // endpoints, unhandled response shapes, etc. - if ( - status === 404 || - status === 400 || - status === 405 || - status === 422 || - OUR_SIDE_RE.test(text) - ) { - return make( - 'our_side', - 'Endpoint/response our check does not handle (our bug or a vendor change).', - ); - } - - // 6. Fleet tiebreaker + conservative default: never blame the customer without - // proof — any remaining ambiguous execution failure is treated as our-side. - if (fleetWide) { - return make('our_side', 'Ambiguous error failing fleet-wide → our-side.'); - } - return make( - 'our_side', - 'Ambiguous execution failure → defaulting to our-side (never blame the customer without proof).', - ); -} diff --git a/apps/api/src/integration-platform/utils/task-check-evaluation.runstatus.spec.ts b/apps/api/src/integration-platform/utils/task-check-evaluation.runstatus.spec.ts index ee88b850d..3ba479e55 100644 --- a/apps/api/src/integration-platform/utils/task-check-evaluation.runstatus.spec.ts +++ b/apps/api/src/integration-platform/utils/task-check-evaluation.runstatus.spec.ts @@ -1,105 +1,32 @@ -import { - decideRunStatus, - type ClassifiableFailure, -} from './task-check-evaluation'; - -const fail = ( - over: Partial = {}, -): ClassifiableFailure => ({ - connectionId: 'c', - checkId: 'k', - resourceId: 'r', - ...over, -}); +import { decideRunStatus } from './task-check-evaluation'; describe('decideRunStatus', () => { - // Static / AWS / GCP / Azure — isDynamic=false → NEVER held; identical to the - // historical mapping (error → failed, else raw status). + // Static / AWS / GCP / Azure — isDynamic=false → never held; historical mapping + // (error → failed, else raw status). describe('non-dynamic (never held)', () => { it('success → success', () => { - expect( - decideRunStatus({ - resultStatus: 'success', - failures: [], - isDynamic: false, - }), - ).toBe('success'); + expect(decideRunStatus({ resultStatus: 'success', isDynamic: false })).toBe('success'); }); - it('failed (even an our-side-looking 404) → failed', () => { - expect( - decideRunStatus({ - resultStatus: 'failed', - failures: [fail({ httpStatus: 404 })], - isDynamic: false, - }), - ).toBe('failed'); + it('failed → failed', () => { + expect(decideRunStatus({ resultStatus: 'failed', isDynamic: false })).toBe('failed'); }); it('execution error → failed', () => { - expect( - decideRunStatus({ - resultStatus: 'error', - failures: [fail({ threw: true })], - isDynamic: false, - }), - ).toBe('failed'); + expect(decideRunStatus({ resultStatus: 'error', isDynamic: false })).toBe('failed'); }); }); - // Dynamic — our-side/transient held as inconclusive; real failures shown. - describe('dynamic', () => { + // Dynamic — comp does NO classification: EVERY non-success is held as + // 'inconclusive' ("pending") and handed to the self-heal agent, the only + // decider of our-bug (fix) vs real fail (show). No error-code logic at all. + describe('dynamic (everything non-success → pending)', () => { it('success → success', () => { - expect( - decideRunStatus({ - resultStatus: 'success', - failures: [], - isDynamic: true, - }), - ).toBe('success'); - }); - it('execution error → inconclusive (held)', () => { - expect( - decideRunStatus({ - resultStatus: 'error', - failures: [], - isDynamic: true, - }), - ).toBe('inconclusive'); - }); - it('all failures our-side (404) → inconclusive (held)', () => { - expect( - decideRunStatus({ - resultStatus: 'failed', - failures: [fail({ httpStatus: 404 })], - isDynamic: true, - }), - ).toBe('inconclusive'); - }); - it('genuine compliance finding (no error signal) → failed (shown)', () => { - expect( - decideRunStatus({ - resultStatus: 'failed', - failures: [fail()], - isDynamic: true, - }), - ).toBe('failed'); + expect(decideRunStatus({ resultStatus: 'success', isDynamic: true })).toBe('success'); }); - it('customer-side (401) → failed (shown — action needed)', () => { - expect( - decideRunStatus({ - resultStatus: 'failed', - failures: [fail({ httpStatus: 401 })], - isDynamic: true, - }), - ).toBe('failed'); + it('failed (a finding, a customer error, anything) → inconclusive (pending)', () => { + expect(decideRunStatus({ resultStatus: 'failed', isDynamic: true })).toBe('inconclusive'); }); - it('mixed held + real → failed (any effective failure surfaces)', () => { - expect( - decideRunStatus({ - resultStatus: 'failed', - failures: [fail({ httpStatus: 404 }), fail({ resourceId: 'r2' })], - isDynamic: true, - }), - ).toBe('failed'); + it('execution error → inconclusive (pending)', () => { + expect(decideRunStatus({ resultStatus: 'error', isDynamic: true })).toBe('inconclusive'); }); }); }); diff --git a/apps/api/src/integration-platform/utils/task-check-evaluation.signals.spec.ts b/apps/api/src/integration-platform/utils/task-check-evaluation.signals.spec.ts deleted file mode 100644 index c829c4584..000000000 --- a/apps/api/src/integration-platform/utils/task-check-evaluation.signals.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { failureSignalsFromEvidence } from './task-check-evaluation'; - -describe('failureSignalsFromEvidence', () => { - it('parses http status from evidence.error like "http_404"', () => { - const s = failureSignalsFromEvidence({ - error: 'http_404', - message: 'not found', - }); - expect(s.httpStatus).toBe(404); - expect(s.errorText).toContain('not found'); - expect(s.threw).toBe(false); - }); - - it('falls back to numeric evidence.status', () => { - expect(failureSignalsFromEvidence({ status: 503 }).httpStatus).toBe(503); - }); - - it('parses spaced/colon HTTP forms so 401/403 are not missed', () => { - // These would otherwise default to our_side (held) instead of customer_side. - expect( - failureSignalsFromEvidence({ message: 'HTTP 401 Unauthorized' }) - .httpStatus, - ).toBe(401); - expect( - failureSignalsFromEvidence({ error: 'HTTP: 403 Forbidden' }).httpStatus, - ).toBe(403); - expect( - failureSignalsFromEvidence({ message: 'HTTP-429 rate limited' }) - .httpStatus, - ).toBe(429); - // A URL must NOT be mistaken for a status. - expect( - failureSignalsFromEvidence({ - message: 'fetch https://x.com/v404/p failed', - }).httpStatus, - ).toBeNull(); - }); - - it('does NOT let an empty message mask the error text', () => { - // `msgStr ?? errStr` kept '' (not null) — an empty message hid the error. - const s = failureSignalsFromEvidence({ - error: 'http_500 upstream boom', - message: '', - }); - expect(s.errorText).toContain('boom'); - expect(s.httpStatus).toBe(500); - }); - - it('parses a STRING evidence.status (e.g. "401", "403 Forbidden")', () => { - expect(failureSignalsFromEvidence({ status: '401' }).httpStatus).toBe(401); - expect( - failureSignalsFromEvidence({ status: '403 Forbidden' }).httpStatus, - ).toBe(403); - expect( - failureSignalsFromEvidence({ status: 'weird' }).httpStatus, - ).toBeNull(); - // Code must be at the START — don't grab an unrelated 3-digit run. - expect( - failureSignalsFromEvidence({ status: 'build 200 ok' }).httpStatus, - ).toBeNull(); - }); - - it('marks threw when the result status is "error"', () => { - expect(failureSignalsFromEvidence({}, 'error').threw).toBe(true); - }); - - it('prefers message over error for errorText and redacts it', () => { - const s = failureSignalsFromEvidence({ - error: 'http_401', - message: 'auth failed for Bearer sk-supersecrettokenvalue123456', - }); - expect(s.errorText).not.toContain('sk-supersecrettokenvalue123456'); - expect(s.errorText).toContain('auth failed'); - }); - - it('returns empty signals for an evidence-less finding (treated as compliance)', () => { - const s = failureSignalsFromEvidence(undefined); - expect(s.httpStatus).toBeNull(); - expect(s.errorText).toBeNull(); - expect(s.threw).toBe(false); - }); -}); diff --git a/apps/api/src/integration-platform/utils/task-check-evaluation.split.spec.ts b/apps/api/src/integration-platform/utils/task-check-evaluation.split.spec.ts deleted file mode 100644 index 2b13d8906..000000000 --- a/apps/api/src/integration-platform/utils/task-check-evaluation.split.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - splitFailuresByDisposition, - ClassifiableFailure, -} from './task-check-evaluation'; - -const f = (over: Partial): ClassifiableFailure => ({ - connectionId: 'icn_1', - checkId: 'chk', - resourceId: 'res', - ...over, -}); - -describe('splitFailuresByDisposition', () => { - it('keeps a genuine compliance finding (no error signal) as effective', () => { - const { effective, held } = splitFailuresByDisposition([f({})]); - expect(effective).toHaveLength(1); - expect(held).toHaveLength(0); - }); - - it('holds our-side failures (404 / unhandled endpoint)', () => { - const { effective, held } = splitFailuresByDisposition([ - f({ httpStatus: 404 }), - f({ errorText: 'not allowed ... scoped to' }), - ]); - expect(effective).toHaveLength(0); - expect(held).toHaveLength(2); - }); - - it('holds transient failures (5xx / timeout)', () => { - const { held } = splitFailuresByDisposition([ - f({ httpStatus: 503 }), - f({ errorText: 'request timed out' }), - ]); - expect(held).toHaveLength(2); - }); - - it('shows proven customer-side failures (401)', () => { - const { effective, held } = splitFailuresByDisposition([f({ httpStatus: 401 })]); - expect(effective).toHaveLength(1); - expect(held).toHaveLength(0); - }); - - it('splits a mixed batch correctly', () => { - const { effective, held } = splitFailuresByDisposition([ - f({}), // compliance -> effective - f({ httpStatus: 401 }), // customer -> effective - f({ httpStatus: 404 }), // our-side -> held - f({ errorText: 'fetch failed' }), // transient -> held - ]); - expect(effective).toHaveLength(2); - expect(held).toHaveLength(2); - }); - - it('holds a customer-looking failure when the whole fleet is failing', () => { - const { effective, held } = splitFailuresByDisposition( - [f({ httpStatus: 403 })], - { passing: 0, failing: 6 }, - ); - expect(effective).toHaveLength(0); - expect(held).toHaveLength(1); - }); -}); diff --git a/apps/api/src/integration-platform/utils/task-check-evaluation.ts b/apps/api/src/integration-platform/utils/task-check-evaluation.ts index 090f46638..0cc504b54 100644 --- a/apps/api/src/integration-platform/utils/task-check-evaluation.ts +++ b/apps/api/src/integration-platform/utils/task-check-evaluation.ts @@ -1,5 +1,4 @@ import { ActiveExceptionSet } from '../../cloud-security/finding-exceptions'; -import { redactSecrets } from './redact-secrets'; /** A failing finding, identified the same way an exception is keyed. */ export interface FailingFinding { @@ -18,9 +17,7 @@ export function countEffectiveFailures( exceptions: ActiveExceptionSet, ): number { if (exceptions.size === 0) return failing.length; - return failing.filter( - (f) => !exceptions.has(f.connectionId, f.checkId, f.resourceId), - ).length; + return failing.filter((f) => !exceptions.has(f.connectionId, f.checkId, f.resourceId)).length; } /** @@ -29,13 +26,14 @@ export function countEffectiveFailures( * - any real (non-excepted) failure → failed * - else if the check evaluated any resource (a passing result OR a finding, * including the case where every finding is excepted) → done - * - else leave unchanged (nothing was evaluated, e.g. an all-errored run — - * indeterminate, not a violation; it retries next tick) + * - else leave unchanged (nothing was evaluated, e.g. an all-errored run). * - * `effectiveFailures` is the non-excepted failure count from - * {@link countEffectiveFailures}; `totalFindings` is the RAW finding count so an - * all-excepted run (effectiveFailures 0, no passing results) still transitions - * to done instead of getting stuck in its prior status. + * For DYNAMIC integrations, EVERY failure is held as 'inconclusive' (pending) and + * counted in `heldCount`, never in `effectiveFailures` — so a held check never + * fails the task (the self-heal agent is the only decider of our-bug vs real + * fail). When the agent reveals a genuine fail, that run persists 'failed' and a + * later evaluation fails the task; when it fixes, the run passes and the task goes + * done. heldCount is always 0 for non-dynamic (static/AWS/GCP/Azure). */ export function decideTaskStatus( effectiveFailures: number, @@ -44,131 +42,26 @@ export function decideTaskStatus( heldCount = 0, ): 'failed' | 'done' | null { if (effectiveFailures > 0) return 'failed'; - // Held (our-side/transient) failures are UNRESOLVED — the self-heal agent is - // still fixing them. Never declare the task done while any check is held, even - // if other checks passed; that would hide an unresolved failure behind a green - // task. Leave it unchanged (indeterminate) until the held checks actually pass - // — the agent's re-run then produces a clean pass and a later run goes done. - // heldCount is always 0 for non-dynamic (static/AWS/GCP/Azure), so unchanged. + // Any held (pending) check is UNRESOLVED — never declare the task done while one + // is pending; that would hide an unresolved failure behind a green task. if (heldCount > 0) return null; if (totalPassing > 0 || totalFindings > 0) return 'done'; return null; } -/** A failing finding plus the signals needed to classify WHY it failed. */ -export interface ClassifiableFailure extends FailingFinding { - /** HTTP status the failure carried, if any. */ - httpStatus?: number | null; - /** Error text from the finding's evidence. MUST be pre-redacted of secrets. */ - errorText?: string | null; - /** True if the runtime threw rather than the vendor returning an error. */ - threw?: boolean; -} - -export interface FailureDisposition { - /** Genuine failures — fail the task + show (compliance findings + proven customer-side). */ - effective: ClassifiableFailure[]; - /** Held failures — our-side bug / transient. Task NOT failed; surfaced as inconclusive. */ - held: ClassifiableFailure[]; -} - /** - * Split failing findings into those that should fail the task (real compliance - * findings + proven customer-side issues) vs those to HOLD as inconclusive - * (our-side bug / transient), so a customer never sees a red for our problem. - * - * For DYNAMIC integrations only — the caller gates this; static/AWS checks keep - * their existing behavior. The classifier is conservative (never blames the - * customer without proof), so ambiguous failures are held, not shown. - * - * `fleet` (optional) is the same check's pass/fail counts across the provider's - * other active connections — a fleet-wide failure is held even if it looks - * customer-like. - */ -export function splitFailuresByDisposition( - failing: ClassifiableFailure[], - _fleet?: { passing: number; failing: number } | null, -): FailureDisposition { - // New model: comp does NO classification. EVERY dynamic failure is HELD as - // 'inconclusive' ("pending", hidden from the customer) and handed to the - // self-heal agent — the ONLY thing that decides our-bug (fix) vs real fail - // (show). So nothing is "effective" here; the agent reveals genuine fails via - // the internal API. (The caller gates this to dynamic integrations only.) - return { effective: [], held: failing }; -} - -/** - * Decide the per-run status stored on an IntegrationCheckRun. Canonical rule, - * shared by ALL run paths (scheduled, manual, and the agent re-run) so a held - * run is classified identically everywhere: - * - base: an execution 'error' → 'failed'; otherwise the raw success/failed. - * - DYNAMIC only: an execution error, or a run whose failures are ALL held - * (our-side/transient → no effective failures), becomes 'inconclusive' — - * the self-heal queue, hidden from the customer. Static/AWS keep the base. - * - * `failures` carries the per-finding signals (from {@link failureSignalsFromEvidence}). + * Decide the per-run status stored on an IntegrationCheckRun. Shared by ALL run + * paths (scheduled, manual, agent re-run). comp does NO classification: for a + * DYNAMIC integration every non-success — a finding, a customer/transport error, + * or a thrown execution error — is held as 'inconclusive' ("pending", hidden from + * the customer) and handed to the self-heal agent, the ONLY decider of our-bug vs + * real fail. Static/AWS/GCP/Azure (isDynamic = false) keep the plain mapping. */ export function decideRunStatus(params: { resultStatus: string; - // Accepted for caller compatibility; no longer used — comp does not classify. - failures?: ClassifiableFailure[]; isDynamic: boolean; }): 'success' | 'failed' | 'inconclusive' { const { resultStatus, isDynamic } = params; if (resultStatus === 'success') return 'success'; - // DYNAMIC: every non-success — a finding, a customer/transport error, OR a - // thrown execution error — is held as 'inconclusive' ("pending", hidden from - // the customer) and handed to the self-heal agent. The AGENT is the only thing - // that decides our-bug (fix) vs real fail (show); comp does no classification. - // Static/AWS/GCP/Azure keep the plain failed mapping (isDynamic = false). return isDynamic ? 'inconclusive' : 'failed'; } - -/** - * Extract classification signals from a failing finding's evidence. `errorText` - * is REDACTED of secrets/PII before it leaves this function. - * - * Defensive about the heterogeneous evidence shapes checks emit: if no signal is - * found, returns empty signals → the failure is treated as a genuine compliance - * finding (today's behavior), so a check is never wrongly held. `resultStatus` - * of 'error' means the check threw → an execution failure. - */ -export function failureSignalsFromEvidence( - evidence: Record | null | undefined, - resultStatus?: string, -): { httpStatus: number | null; errorText: string | null; threw: boolean } { - const ev = evidence ?? {}; - const errStr = typeof ev.error === 'string' ? ev.error : null; - const msgStr = typeof ev.message === 'string' ? ev.message : null; - - let httpStatus: number | null = null; - // Search BOTH error and message — the status often lives in the human message - // ('HTTP 401 Unauthorized'), not the error code. Tolerate space/colon/_/- - // separators so 'http_404', 'HTTP 401', 'HTTP: 403', 'HTTP-429' all parse; - // otherwise a customer-actionable 401/403 would be missed and default to - // our_side (held) instead of customer_side (shown). Won't match a URL. - const m = `${errStr ?? ''} ${msgStr ?? ''}`.match(/\bhttp[\s:_-]*(\d{3})\b/i); - if (m) httpStatus = Number(m[1]); - // evidence.status may be a number (404) OR a status-code string ('404', - // '401 Unauthorized'). For strings the code must be at the START (anchored) so - // we don't grab an unrelated 3-digit run from arbitrary text (e.g. 'build 200'). - if (httpStatus == null && ev.status != null) { - const n = - typeof ev.status === 'number' - ? ev.status - : Number( - String(ev.status) - .trim() - .match(/^(\d{3})\b/)?.[1], - ); - if (Number.isFinite(n) && n >= 100 && n < 600) httpStatus = n; - } - - // Use the message ONLY when it has content; an empty-string message must not - // mask the error text (?? keeps '' because it's not null/undefined). - const rawText = msgStr && msgStr.trim() ? msgStr : (errStr ?? ''); - const errorText = rawText ? redactSecrets(rawText) : null; - const threw = resultStatus === 'error'; - - return { httpStatus, errorText, threw }; -} diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts index f78ff5a17..8cb72f536 100644 --- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts +++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts @@ -17,9 +17,7 @@ import { countEffectiveFailures, decideTaskStatus, decideRunStatus, - splitFailuresByDisposition, - failureSignalsFromEvidence, - type ClassifiableFailure, + type FailingFinding, } from '../../integration-platform/utils/task-check-evaluation'; /** @@ -225,8 +223,9 @@ export const runTaskIntegrationChecks = task({ let hasExecutionErrors = false; // Failing findings (keyed like an exception) so task status can exclude // explicitly-excepted ones below. Carries redacted error signals so the - // self-heal layer can classify our-side failures (dynamic integrations). - const failingFindings: ClassifiableFailure[] = []; + // identity only — comp never classifies; the self-heal agent reads the stored + // findings/evidence and decides our-bug vs real fail itself. + const failingFindings: FailingFinding[] = []; // Run only the checks that apply to this task try { @@ -304,7 +303,6 @@ export const runTaskIntegrationChecks = task({ // never shows the customer a red), static/AWS → 'failed'. status: decideRunStatus({ resultStatus: 'error', - failures: [], isDynamic, }), startedAt: new Date(), @@ -329,15 +327,13 @@ export const runTaskIntegrationChecks = task({ // exception) so task status can exclude explicitly-excepted ones. totalFindings += checkResult.result.findings.length; totalPassing += checkResult.result.passingResults.length; - // Build this check's failing findings (with redacted signals) once — - // reused for the task-level decision and the per-check run status. + // Build this check's failing findings — identity only. comp does NOT + // classify; the self-heal agent reads the stored findings/evidence and + // decides our-bug vs real fail itself. const checkFailures = checkResult.result.findings.map((f) => ({ connectionId, checkId: checkResult.checkId, resourceId: f.resourceId, - // Redacted error signals so the self-heal layer can classify - // our-side/transient failures and hold them as inconclusive. - ...failureSignalsFromEvidence(f.evidence, checkResult.status), })); failingFindings.push(...checkFailures); if (checkResult.status === 'error') { @@ -350,7 +346,6 @@ export const runTaskIntegrationChecks = task({ // base success/failed mapping. const runStatus = decideRunStatus({ resultStatus: checkResult.status, - failures: checkFailures, isDynamic, }); @@ -450,9 +445,9 @@ export const runTaskIntegrationChecks = task({ // the self-heal layer investigates/fixes them. Static/AWS behavior is // unchanged. Safe degradation: a finding with no readable error signal // classifies as a real (compliance) failure, exactly as today. - const statusFailures = isDynamic - ? splitFailuresByDisposition(failingFindings).effective - : failingFindings; + // DYNAMIC: every failure is held (pending) — comp never classifies, the + // agent decides. Static/AWS: unchanged (no holding). + const statusFailures = isDynamic ? [] : failingFindings; const heldCount = failingFindings.length - statusFailures.length; if (heldCount > 0) { logger.info( From 923a9e997a2a007bc80372f2f6622ca24021242d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 30 Jun 2026 14:53:22 -0400 Subject: [PATCH 04/18] feat(devices): show integration-imported devices in People tab + Intune/JumpCloud device sync Devices imported from dynamic integrations now render correctly in the People tab instead of being mislabeled as failing agent devices. - agent-devices route + types: pass the real device source (agent/fleet/ integration) and the importing provider; drop integration rows that duplicate an agent serial - People > Devices table: Source column, source filter, "Not tracked" compliance for imported devices (no false-red checks), agent-only online status - Employee detail page, People compliance roll-up, compliance chart, CSV export, and people score: treat imported devices as inventory-only, not compliance-tracked, so they no longer skew compliance or suppress Fleet - tools/device-sync-definitions: version-controlled Intune + JumpCloud deviceSyncDefinition DSL, an offline mock test harness, and an apply script (the DSL otherwise lives only in the DB) Tests: devices suite, people-score helper, and 13 definition mock tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015iDU78gxNH9Wp9sex1BDLS --- .../frameworks-people-score.helper.ts | 4 + .../components/EmployeeDevice.tsx | 39 ++- .../[orgId]/people/[employeeId]/page.tsx | 43 ++- .../components/compute-device-status-map.ts | 4 + .../DeviceAgentDevicesList.test.tsx | 85 +++++ .../components/DeviceAgentDevicesList.tsx | 294 +++++------------- .../components/DeviceComplianceChart.tsx | 13 +- .../devices/components/DeviceDetails.tsx | 75 ++++- .../devices/components/DeviceListCells.tsx | 288 +++++++++++++++++ .../devices/components/DevicesTabContent.tsx | 11 +- .../people/devices/lib/devices-csv.test.ts | 24 +- .../[orgId]/people/devices/lib/devices-csv.ts | 31 +- .../[orgId]/people/devices/types/index.ts | 13 +- .../src/app/api/people/agent-devices/route.ts | 131 ++++++-- tools/device-sync-definitions/README.md | 60 ++++ tools/device-sync-definitions/apply.mjs | 148 +++++++++ tools/device-sync-definitions/intune.mjs | 112 +++++++ tools/device-sync-definitions/jumpcloud.mjs | 159 ++++++++++ tools/device-sync-definitions/test.mjs | 270 ++++++++++++++++ 19 files changed, 1504 insertions(+), 300 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx create mode 100644 tools/device-sync-definitions/README.md create mode 100644 tools/device-sync-definitions/apply.mjs create mode 100644 tools/device-sync-definitions/intune.mjs create mode 100644 tools/device-sync-definitions/jumpcloud.mjs create mode 100644 tools/device-sync-definitions/test.mjs diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.ts b/apps/api/src/frameworks/frameworks-people-score.helper.ts index ac0b11671..7f0a0940a 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.ts @@ -158,6 +158,10 @@ async function getMembersWithInstalledDevices({ where: { organizationId, memberId: { in: memberIds }, + // Integration-imported devices are inventory records, not proof that + // the member installed the device agent — exclude them so they don't + // falsely satisfy the device-agent step in the people score. + source: { not: 'integration' }, }, select: { memberId: true }, distinct: ['memberId'], diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDevice.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDevice.tsx index 2e0bc5e7d..1435c10fb 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDevice.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDevice.tsx @@ -49,6 +49,27 @@ function staleTooltipCopy(daysSinceLastCheckIn: number | null): string { } function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { + if (device.source === 'integration') { + const provider = device.integrationProvider?.name ?? 'an integration'; + return ( +
+ Not tracked + + + event.stopPropagation()} + > + + + + {`This device was imported from ${provider}. CompAI doesn't collect compliance checks for imported devices — install the CompAI agent to track its security posture.`} + + + +
+ ); + } if (device.complianceStatus === 'stale') { return (
@@ -115,8 +136,13 @@ export function EmployeeDevice({
{CHECK_FIELDS.map(({ key, dbKey, label }) => { + const isIntegration = memberDevice.source === 'integration'; const isFleetUnsupported = memberDevice.source === 'fleet' && key !== 'diskEncryptionEnabled'; + const isUntracked = isIntegration || isFleetUnsupported; + const untrackedCopy = isIntegration + ? 'Not collected for imported devices' + : 'Not tracked by Fleet'; const isStale = memberDevice.complianceStatus === 'stale'; const passed = memberDevice[key]; const details = memberDevice.checkDetails?.[dbKey]; @@ -124,19 +150,19 @@ export function EmployeeDevice({
{label} - {!isFleetUnsupported && !isStale && details?.message && ( + {!isUntracked && !isStale && details?.message && (

{details.message}

)} - {isFleetUnsupported && ( -

Not tracked by Fleet

+ {isUntracked && ( +

{untrackedCopy}

)} - {!isFleetUnsupported && !isStale && details?.exception && ( + {!isUntracked && !isStale && details?.exception && (

{details.exception}

)}
- {isFleetUnsupported ? ( + {isUntracked ? ( N/A ) : isStale ? ( @@ -153,7 +179,8 @@ export function EmployeeDevice({
{memberDevice.lastCheckIn && (

- Last check-in: {new Date(memberDevice.lastCheckIn).toLocaleString()} + {memberDevice.source === 'integration' ? 'Last synced' : 'Last check-in'}:{' '} + {new Date(memberDevice.lastCheckIn).toLocaleString()}

)} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index 80960daa7..f43221e2d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -293,7 +293,7 @@ const getMemberDevice = async ( memberId: string, organizationId: string, ): Promise => { - const device = await db.device.findFirst({ + const devices = await db.device.findMany({ where: { memberId, organizationId }, include: { member: { @@ -310,10 +310,41 @@ const getMemberDevice = async ( orderBy: { installedAt: 'desc' }, }); - if (!device) { + if (devices.length === 0) { return null; } + // An agent device carries real compliance data; an integration import does + // not. Prefer the richest source so the detail page never shows an imported + // device as a failing agent device. Order of richness: agent > fleet > integration. + const device = + devices.find((d) => d.source === 'agent') ?? + devices.find((d) => d.source === 'fleet') ?? + devices[0]; + + const source: DeviceWithChecks['source'] = + device.source === 'integration' + ? 'integration' + : device.source === 'fleet' + ? 'fleet' + : 'device_agent'; + + // Resolve the provider (name/slug) so an imported device shows its provenance + // instead of being mislabeled as an agent device. + let integrationProvider: DeviceWithChecks['integrationProvider']; + if (source === 'integration' && device.integrationConnectionId) { + const connection = await db.integrationConnection.findFirst({ + where: { id: device.integrationConnectionId, organizationId }, + select: { provider: { select: { slug: true, name: true } } }, + }); + if (connection?.provider) { + integrationProvider = { + slug: connection.provider.slug, + name: connection.provider.name, + }; + } + } + const complianceStatus = getDeviceComplianceStatus({ isCompliant: device.isCompliant, lastCheckIn: device.lastCheckIn, @@ -336,14 +367,18 @@ const getMemberDevice = async ( lastCheckIn: device.lastCheckIn?.toISOString() ?? null, agentVersion: device.agentVersion, installedAt: device.installedAt.toISOString(), + memberId: device.memberId, user: { name: device.member.user.name, email: device.member.user.email, }, - source: 'device_agent' as const, + source, + ...(integrationProvider ? { integrationProvider } : {}), complianceStatus, daysSinceLastCheckIn: daysSinceCheckIn(device.lastCheckIn), hasActiveAgentSession: - !!device.agentSession && device.agentSession.expiresAt.getTime() > Date.now(), + source === 'device_agent' && + !!device.agentSession && + device.agentSession.expiresAt.getTime() > Date.now(), }; }; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts index a78a5282b..ac0af2ad6 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts @@ -35,6 +35,10 @@ export function computeDeviceStatusMap({ const agentRollup = new Map(); for (const d of agentDevices) { + // Integration-imported devices carry no compliance data, so they must not + // set a member's status (it would falsely read non-compliant/stale) nor + // suppress the richer Fleet fallback below. Only true agent devices count. + if (d.source !== 'device_agent') continue; if (!d.memberId || !complianceSet.has(d.memberId)) continue; const prev = agentRollup.get(d.memberId); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx index 020736e64..2ccb736f1 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx @@ -183,3 +183,88 @@ describe('DeviceAgentDevicesList', () => { ).not.toBeInTheDocument(); }); }); + +function makeIntegrationDevice( + overrides: Partial = {}, +): DeviceWithChecks { + return makeDevice({ + id: 'dev_int', + name: 'Imported Mac', + // Imported devices carry no real compliance data — defaults are all false. + isCompliant: false, + diskEncryptionEnabled: false, + antivirusEnabled: false, + passwordPolicySet: false, + screenLockEnabled: false, + agentVersion: null, + hasActiveAgentSession: false, + // Even though lastCheckIn (= last sync) is fresh, the server still derives + // non_compliant; the UI must override this to "Not tracked" by source. + complianceStatus: 'non_compliant', + source: 'integration', + integrationProvider: { slug: 'kandji', name: 'Kandji' }, + ...overrides, + }); +} + +describe('DeviceAgentDevicesList — integration-imported devices', () => { + it('labels the device with its integration provider in the Source column', () => { + render(); + expect(screen.getByText('Kandji')).toBeInTheDocument(); + }); + + it('shows "Not tracked" compliance instead of a false "No"', () => { + render(); + expect(screen.getByText('Not tracked')).toBeInTheDocument(); + expect(screen.queryByText('No')).not.toBeInTheDocument(); + expect(screen.queryByText('Yes')).not.toBeInTheDocument(); + }); + + it('renders the "not tracked" explainer tooltip trigger', () => { + render(); + expect( + screen.getByRole('button', { name: /Why is compliance not tracked\?/i }), + ).toBeInTheDocument(); + }); + + it('does not present an imported device as Online', () => { + render(); + expect(screen.queryByTitle('Online')).not.toBeInTheDocument(); + expect( + screen.getByTitle('Imported device (no live status)'), + ).toBeInTheDocument(); + }); + + it('renders a source filter only when more than one source is present', () => { + const { rerender } = render( + , + ); + expect(screen.queryByLabelText('Filter by source')).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByLabelText('Filter by source')).toBeInTheDocument(); + }); + + it('filters the table to a single source when selected', () => { + render( + , + ); + expect(screen.getByText('Agent Mac')).toBeInTheDocument(); + expect(screen.getByText('Imported Mac')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Filter by source'), { + target: { value: 'Kandji' }, + }); + expect(screen.queryByText('Agent Mac')).not.toBeInTheDocument(); + expect(screen.getByText('Imported Mac')).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx index 63822fd0a..b4055f46b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx @@ -1,12 +1,7 @@ 'use client'; import { - Badge, Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, Empty, EmptyDescription, EmptyHeader, @@ -17,21 +12,11 @@ import { Stack, Table, TableBody, - TableCell, TableHead, TableHeader, TableRow, - Text, } from '@trycompai/design-system'; -import { - Download, - Information, - OverflowMenuVertical, - Search, - TrashCan, -} from '@trycompai/design-system/icons'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; -import Link from 'next/link'; +import { Download, Search } from '@trycompai/design-system/icons'; import { useParams } from 'next/navigation'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -44,6 +29,7 @@ import { devicesCsvFilename, downloadDevicesCsv, } from '../lib/devices-csv'; +import { DeviceTableRow, sourceLabel } from './DeviceListCells'; import { DeviceDetails } from './DeviceDetails'; import { RemoveDeviceAlert } from '../../all/components/RemoveDeviceAlert'; @@ -51,129 +37,6 @@ export interface DeviceAgentDevicesListProps { devices: DeviceWithChecks[]; } -const CHECK_FIELDS = [ - { key: 'diskEncryptionEnabled' as const, label: 'Disk Encryption' }, - { key: 'antivirusEnabled' as const, label: 'Antivirus' }, - { key: 'passwordPolicySet' as const, label: 'Password Policy' }, - { key: 'screenLockEnabled' as const, label: 'Screen Lock' }, -]; - -const PLATFORM_LABELS: Record = { - macos: 'macOS', - windows: 'Windows', - linux: 'Linux', -}; - -function formatTimeAgo(dateString: string | null): string { - if (!dateString) return 'Never'; - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - - if (diffHours < 1) return 'Just now'; - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; -} - -/** Device is considered online if it checked in within the last 2 hours */ -function isDeviceOnline(lastCheckIn: string | null): boolean { - if (!lastCheckIn) return false; - const diffMs = Date.now() - new Date(lastCheckIn).getTime(); - return diffMs < 2 * 60 * 60 * 1000; -} - -function staleLabel(daysSinceLastCheckIn: number | null): string { - return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; -} - -function staleTooltipCopy(daysSinceLastCheckIn: number | null): string { - return daysSinceLastCheckIn === null - ? "This device was registered but hasn't sent a compliance check yet. If it's not new, the agent may not be running or the device may be offline." - : "This device hasn't reported to CompAI in over 7 days, so we can't verify its current compliance. It may be offline, the agent may need to be updated, or the device may no longer be in use. Check with the employee."; -} - -function UserNameCell({ device, orgId }: { device: DeviceWithChecks; orgId: string }) { - const memberId = device.memberId; - - if (!memberId) { - return ( -
- {device.user.name} - {device.user.email} -
- ); - } - - return ( -
- e.stopPropagation()} - > - {device.user.name} - - {device.user.email} -
- ); -} - -function CompliantBadge({ device }: { device: DeviceWithChecks }) { - if (device.complianceStatus === 'stale') { - return ( -
- {staleLabel(device.daysSinceLastCheckIn)} - - - - - - - {staleTooltipCopy(device.daysSinceLastCheckIn)} - - - -
- ); - } - if (device.complianceStatus === 'compliant') { - return Yes; - } - return No; -} - -function CheckBadges({ device }: { device: DeviceWithChecks }) { - if (device.complianceStatus === 'stale') { - return ( -
- {CHECK_FIELDS.map(({ key, label }) => ( - - — - - ))} -
- ); - } - return ( -
- {CHECK_FIELDS.map(({ key, label }) => ( - - {label} - - ))} -
- ); -} - export const DeviceAgentDevicesList = ({ devices, }: DeviceAgentDevicesListProps) => { @@ -185,22 +48,34 @@ export const DeviceAgentDevicesList = ({ const [selectedDevice, setSelectedDevice] = useState(null); const [actionDevice, setActionDevice] = useState(null); const [searchQuery, setSearchQuery] = useState(''); + const [sourceFilter, setSourceFilter] = useState('all'); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(50); const [isRemoveDeviceAlertOpen, setIsRemoveDeviceAlertOpen] = useState(false); const [isRemovingDevice, setIsRemovingDevice] = useState(false); + // Distinct source labels present, so the filter only offers sources that exist + // (e.g. "Comp Agent", "Kandji"). + const sourceOptions = useMemo(() => { + const labels = new Set(devices.map((d) => sourceLabel(d))); + return Array.from(labels).sort(); + }, [devices]); + const filteredDevices = useMemo(() => { - if (!searchQuery) return devices; const query = searchQuery.toLowerCase(); - return devices.filter( - (device) => + return devices.filter((device) => { + if (sourceFilter !== 'all' && sourceLabel(device) !== sourceFilter) { + return false; + } + if (!query) return true; + return ( device.name.toLowerCase().includes(query) || device.user.name.toLowerCase().includes(query) || device.user.email.toLowerCase().includes(query) || - device.platform.toLowerCase().includes(query), - ); - }, [devices, searchQuery]); + device.platform.toLowerCase().includes(query) + ); + }); + }, [devices, searchQuery, sourceFilter]); const pageCount = Math.max(1, Math.ceil(filteredDevices.length / perPage)); const paginatedDevices = useMemo(() => { @@ -251,20 +126,40 @@ export const DeviceAgentDevicesList = ({ return (
-
- - - - - +
+ + + + + { + setSearchQuery(e.target.value); + setPage(1); + }} + /> + +
+ {sourceOptions.length > 1 && ( + + )}
+ + + {`This device was imported from ${provider}. CompAI doesn't collect compliance checks for imported devices — install the CompAI agent to track its security posture.`} + + + +
+ ); + } if (device.complianceStatus === 'stale') { return (
@@ -101,19 +126,27 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => {
- + {device.source !== 'integration' && ( + + )} {device.name} - - {isDeviceOnline(device.lastCheckIn) ? 'Online' : 'Offline'} - + {device.source === 'integration' ? ( + + {`Imported • ${device.integrationProvider?.name ?? 'Integration'}`} + + ) : ( + + {isDeviceOnline(device.lastCheckIn) ? 'Online' : 'Offline'} + + )} {device.source === 'fleet' && Fleet (Legacy)}
@@ -162,7 +195,7 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => {
- Last Check-in + {device.source === 'integration' ? 'Last synced' : 'Last Check-in'} {device.lastCheckIn ? new Date(device.lastCheckIn).toLocaleString() : 'Never'} @@ -170,10 +203,12 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => {
- Agent Version + {device.source === 'integration' ? 'Source' : 'Agent Version'} - {device.agentVersion ?? 'N/A'} + {device.source === 'integration' + ? (device.integrationProvider?.name ?? 'Integration') + : (device.agentVersion ?? 'N/A')}
@@ -199,7 +234,13 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { {CHECK_FIELDS.map(({ key, dbKey, label }) => { - const isFleetUnsupported = device.source === 'fleet' && key !== 'diskEncryptionEnabled'; + const isIntegration = device.source === 'integration'; + const isFleetUnsupported = + device.source === 'fleet' && key !== 'diskEncryptionEnabled'; + const isUntracked = isIntegration || isFleetUnsupported; + const untrackedCopy = isIntegration + ? 'Not collected for imported devices' + : 'Not tracked by Fleet'; const isStale = device.complianceStatus === 'stale'; const passed = device[key]; const details = device.checkDetails?.[dbKey]; @@ -212,15 +253,15 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { - {isFleetUnsupported - ? 'Not tracked by Fleet' + {isUntracked + ? untrackedCopy : isStale ? '—' : (details?.message ?? '—')} - {isFleetUnsupported ? ( + {isUntracked ? ( N/A ) : isStale ? ( = { + macos: 'macOS', + windows: 'Windows', + linux: 'Linux', +}; + +export function formatTimeAgo(dateString: string | null): string { + if (!dateString) return 'Never'; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + + if (diffHours < 1) return 'Just now'; + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +/** Device is considered online if it checked in within the last 2 hours. */ +export function isDeviceOnline(lastCheckIn: string | null): boolean { + if (!lastCheckIn) return false; + const diffMs = Date.now() - new Date(lastCheckIn).getTime(); + return diffMs < 2 * 60 * 60 * 1000; +} + +function staleLabel(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; +} + +function staleTooltipCopy(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null + ? "This device was registered but hasn't sent a compliance check yet. If it's not new, the agent may not be running or the device may be offline." + : "This device hasn't reported to CompAI in over 7 days, so we can't verify its current compliance. It may be offline, the agent may need to be updated, or the device may no longer be in use. Check with the employee."; +} + +/** True for devices whose compliance posture CompAI does not collect (imported via an integration). */ +export function isComplianceTracked(device: DeviceWithChecks): boolean { + return device.source === 'device_agent'; +} + +/** Human label for where a device came from. */ +export function sourceLabel(device: DeviceWithChecks): string { + if (device.source === 'integration') { + return device.integrationProvider?.name ?? 'Integration'; + } + if (device.source === 'fleet') return 'Fleet'; + return 'Comp Agent'; +} + +function InfoTooltip({ label, copy }: { label: string; copy: string }) { + return ( + + + + + + {copy} + + + ); +} + +export function SourceBadge({ device }: { device: DeviceWithChecks }) { + return {sourceLabel(device)}; +} + +export function UserNameCell({ + device, + orgId, +}: { + device: DeviceWithChecks; + orgId: string; +}) { + const memberId = device.memberId; + + if (!memberId) { + return ( +
+ {device.user.name} + {device.user.email} +
+ ); + } + + return ( +
+ e.stopPropagation()} + > + {device.user.name} + + {device.user.email} +
+ ); +} + +export function CompliantBadge({ device }: { device: DeviceWithChecks }) { + // Integration-imported devices are inventory records, not compliance records — + // CompAI never ran security checks on them, so showing "No" (red) would be a + // false negative. Present them as untracked instead. + if (!isComplianceTracked(device)) { + const provider = device.integrationProvider?.name ?? 'an integration'; + return ( +
+ Not tracked + +
+ ); + } + + if (device.complianceStatus === 'stale') { + return ( +
+ {staleLabel(device.daysSinceLastCheckIn)} + +
+ ); + } + if (device.complianceStatus === 'compliant') { + return Yes; + } + return No; +} + +export function CheckBadges({ device }: { device: DeviceWithChecks }) { + if (!isComplianceTracked(device) || device.complianceStatus === 'stale') { + const reason = !isComplianceTracked(device) + ? 'not collected for imported devices' + : 'unknown (device is stale)'; + return ( +
+ {CHECK_FIELDS.map(({ key, label }) => ( + + — + + ))} +
+ ); + } + return ( +
+ {CHECK_FIELDS.map(({ key, label }) => ( + + {label} + + ))} +
+ ); +} + +export interface DeviceTableRowProps { + device: DeviceWithChecks; + orgId: string; + canRemoveDevice: boolean; + onSelect: (device: DeviceWithChecks) => void; + onRequestRemove: (device: DeviceWithChecks) => void; +} + +export function DeviceTableRow({ + device, + orgId, + canRemoveDevice, + onSelect, + onRequestRemove, +}: DeviceTableRowProps) { + const isAgent = device.source === 'device_agent'; + const showOnline = isAgent && isDeviceOnline(device.lastCheckIn); + return ( + onSelect(device)} style={{ cursor: 'pointer' }}> + +
+ + + {device.name} + +
+
+ + + + +
+ {PLATFORM_LABELS[device.platform] ?? device.platform} + + {device.osVersion} + +
+
+ + + + + {formatTimeAgo(device.lastCheckIn)} + + + + + + + + +
+ + e.stopPropagation()} + className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + + + + { + e.stopPropagation(); + onRequestRemove(device); + }} + variant="destructive" + > + + Remove Device + + + +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx index 45b081859..06eff5dda 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx @@ -25,11 +25,16 @@ export function DevicesTabContent({ isCurrentUserOwner }: DevicesTabContentProps error: fleetError, } = useFleetHosts(); - // Filter out Fleet hosts for members who already have device-agent devices. - // Device agent takes priority over Fleet. + // Filter out Fleet hosts for members who already have a device-agent device. + // Device agent takes priority over Fleet. Integration-imported devices are + // inventory-only (no compliance data) and must NOT suppress a richer Fleet + // host, so only true agent devices count toward de-duplication. const filteredFleetDevices = useMemo(() => { const memberIdsWithAgent = new Set( - agentDevices.map((d) => d.memberId).filter(Boolean), + agentDevices + .filter((d) => d.source === 'device_agent') + .map((d) => d.memberId) + .filter(Boolean), ); return fleetHosts.filter( (host) => !host.member_id || !memberIdsWithAgent.has(host.member_id), diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts index 9069e85ae..de70a8b90 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts @@ -72,6 +72,7 @@ describe('buildDevicesCsv', () => { 'yes', 'yes', 'yes', + 'Comp Agent', ].join(','), ); }); @@ -90,7 +91,28 @@ describe('buildDevicesCsv', () => { const cells = row.split(','); expect(cells).toContain('stale'); expect(cells[7]).toBe('51'); // days since sync - expect(cells.slice(-4)).toEqual(['no', 'no', 'yes', 'yes']); + expect(cells.slice(9, 13)).toEqual(['no', 'no', 'yes', 'yes']); // the four checks + }); + + it('exports imported devices as not_tracked / n/a with the provider as source', () => { + const csv = buildDevicesCsv([ + makeDevice({ + source: 'integration', + integrationProvider: { slug: 'kandji', name: 'Kandji' }, + // Imported devices carry only defaults — must NOT export as non_compliant/no. + isCompliant: false, + complianceStatus: 'non_compliant', + diskEncryptionEnabled: false, + antivirusEnabled: false, + passwordPolicySet: false, + screenLockEnabled: false, + agentVersion: null, + }), + ]); + const cells = stripBom(csv).slice(0, -2).split('\r\n')[1].split(','); + expect(cells[8]).toBe('not_tracked'); // status + expect(cells.slice(9, 13)).toEqual(['n/a', 'n/a', 'n/a', 'n/a']); + expect(cells[13]).toBe('Kandji'); // source }); it('represents never-synced devices with empty last-check-in and empty days', () => { diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts index 3aa656bf6..997cde0d3 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts @@ -14,6 +14,7 @@ export const DEVICES_CSV_HEADER = [ 'Antivirus', 'Password Policy', 'Screen Lock', + 'Source', ].join(','); const FORMULA_TRIGGER = /^[=+\-@\t\r]/; @@ -34,9 +35,20 @@ function yesNo(value: boolean): 'yes' | 'no' { return value ? 'yes' : 'no'; } +function csvSourceLabel(d: DeviceWithChecks): string { + if (d.source === 'integration') return d.integrationProvider?.name ?? 'Integration'; + if (d.source === 'fleet') return 'Fleet'; + return 'Comp Agent'; +} + export function buildDevicesCsv(devices: DeviceWithChecks[]): string { - const rows = devices.map((d) => - [ + const rows = devices.map((d) => { + // Only agent devices carry real compliance data. For imported/fleet devices, + // export "not_tracked"/"n/a" rather than a misleading non_compliant + "no". + const tracked = d.source === 'device_agent'; + const status = tracked ? d.complianceStatus : 'not_tracked'; + const check = (value: boolean) => (tracked ? yesNo(value) : 'n/a'); + return [ escapeCell(d.name), escapeCell(d.user.name), escapeCell(d.user.email), @@ -45,13 +57,14 @@ export function buildDevicesCsv(devices: DeviceWithChecks[]): string { escapeCell(d.agentVersion ?? ''), escapeCell(d.lastCheckIn ?? ''), escapeCell(d.daysSinceLastCheckIn ?? ''), - escapeCell(d.complianceStatus), - escapeCell(yesNo(d.diskEncryptionEnabled)), - escapeCell(yesNo(d.antivirusEnabled)), - escapeCell(yesNo(d.passwordPolicySet)), - escapeCell(yesNo(d.screenLockEnabled)), - ].join(','), - ); + escapeCell(status), + escapeCell(check(d.diskEncryptionEnabled)), + escapeCell(check(d.antivirusEnabled)), + escapeCell(check(d.passwordPolicySet)), + escapeCell(check(d.screenLockEnabled)), + escapeCell(csvSourceLabel(d)), + ].join(','); + }); // RFC 4180: records separated by CRLF; trailing CRLF after the final record. // Prepend a UTF-8 BOM so Excel correctly detects UTF-8 encoding for non-ASCII data. return '\uFEFF' + [DEVICES_CSV_HEADER, ...rows].join('\r\n') + '\r\n'; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts index 149a5573f..3ebb9834f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts @@ -34,7 +34,18 @@ export interface DeviceWithChecks { email: string; }; /** Indicates which system reported this device */ - source: 'device_agent' | 'fleet'; + source: 'device_agent' | 'fleet' | 'integration'; + /** + * Set only when `source === 'integration'`: the provider that imported this + * device, so the UI can label its provenance instead of mislabeling it as an + * agent device. `logoUrl` is optional — the DB provider row has no logo, so it + * may be filled later from the manifest registry. + */ + integrationProvider?: { + slug: string; + name: string; + logoUrl?: string; + }; /** Derived on the server; 'stale' = no check-in for >= 7 days. */ complianceStatus: DeviceComplianceStatus; /** Whole days since last check-in, or null when never synced. */ diff --git a/apps/app/src/app/api/people/agent-devices/route.ts b/apps/app/src/app/api/people/agent-devices/route.ts index 286459fa7..30c14386f 100644 --- a/apps/app/src/app/api/people/agent-devices/route.ts +++ b/apps/app/src/app/api/people/agent-devices/route.ts @@ -8,6 +8,13 @@ import { } from '@trycompai/utils/devices'; import type { CheckDetails, DeviceWithChecks } from '@/app/(app)/[orgId]/people/devices/types'; +/** Maps the DB `DeviceSource` enum to the frontend source discriminant. */ +function mapSource(source: string): DeviceWithChecks['source'] { + if (source === 'integration') return 'integration'; + if (source === 'fleet') return 'fleet'; + return 'device_agent'; +} + export async function GET() { const session = await auth.api.getSession({ headers: await headers() }); const organizationId = session?.session.activeOrganizationId; @@ -35,40 +42,98 @@ export async function GET() { orderBy: { installedAt: 'desc' }, }); - const data: DeviceWithChecks[] = devices.map((device) => { - const complianceStatus = getDeviceComplianceStatus({ - isCompliant: device.isCompliant, - lastCheckIn: device.lastCheckIn, - }); - return { - id: device.id, - name: device.name, - hostname: device.hostname, - platform: device.platform as 'macos' | 'windows' | 'linux', - osVersion: device.osVersion, - serialNumber: device.serialNumber, - hardwareModel: device.hardwareModel, - isCompliant: device.isCompliant, - diskEncryptionEnabled: device.diskEncryptionEnabled, - antivirusEnabled: device.antivirusEnabled, - passwordPolicySet: device.passwordPolicySet, - screenLockEnabled: device.screenLockEnabled, - checkDetails: (device.checkDetails as CheckDetails) ?? null, - lastCheckIn: device.lastCheckIn?.toISOString() ?? null, - agentVersion: device.agentVersion, - installedAt: device.installedAt.toISOString(), - memberId: device.memberId, - user: { - name: device.member.user.name, - email: device.member.user.email, + // Resolve provider name/slug for integration-sourced devices in one batched + // query, so each imported device shows its real provenance (e.g. "Kandji") + // instead of being mislabeled as an agent device. The DB provider row has no + // logo, so logoUrl is intentionally left undefined here. + const connectionIds = Array.from( + new Set( + devices + .filter((d) => d.source === 'integration' && d.integrationConnectionId) + .map((d) => d.integrationConnectionId as string), + ), + ); + + const providerByConnectionId = new Map(); + if (connectionIds.length > 0) { + const connections = await db.integrationConnection.findMany({ + where: { id: { in: connectionIds }, organizationId }, + select: { + id: true, + provider: { select: { slug: true, name: true } }, }, - source: 'device_agent' as const, - complianceStatus, - daysSinceLastCheckIn: daysSinceCheckIn(device.lastCheckIn), - hasActiveAgentSession: - !!device.agentSession && device.agentSession.expiresAt.getTime() > Date.now(), - }; - }); + }); + for (const conn of connections) { + if (conn.provider) { + providerByConnectionId.set(conn.id, { + slug: conn.provider.slug, + name: conn.provider.name, + }); + } + } + } + + // An agent device is always richer than an integration import of the same + // physical machine (it carries live compliance). The backend already refuses + // to overwrite an agent serial, but guard the read path too: if a serial is + // owned by an agent device, drop any integration row that shares it. + const agentSerials = new Set( + devices + .filter((d) => d.source !== 'integration' && d.serialNumber) + .map((d) => d.serialNumber as string), + ); + + const data: DeviceWithChecks[] = devices + .filter( + (device) => + !( + device.source === 'integration' && + device.serialNumber && + agentSerials.has(device.serialNumber) + ), + ) + .map((device) => { + const source = mapSource(device.source); + const complianceStatus = getDeviceComplianceStatus({ + isCompliant: device.isCompliant, + lastCheckIn: device.lastCheckIn, + }); + const provider = + source === 'integration' && device.integrationConnectionId + ? providerByConnectionId.get(device.integrationConnectionId) + : undefined; + return { + id: device.id, + name: device.name, + hostname: device.hostname, + platform: device.platform as 'macos' | 'windows' | 'linux', + osVersion: device.osVersion, + serialNumber: device.serialNumber, + hardwareModel: device.hardwareModel, + isCompliant: device.isCompliant, + diskEncryptionEnabled: device.diskEncryptionEnabled, + antivirusEnabled: device.antivirusEnabled, + passwordPolicySet: device.passwordPolicySet, + screenLockEnabled: device.screenLockEnabled, + checkDetails: (device.checkDetails as CheckDetails) ?? null, + lastCheckIn: device.lastCheckIn?.toISOString() ?? null, + agentVersion: device.agentVersion, + installedAt: device.installedAt.toISOString(), + memberId: device.memberId, + user: { + name: device.member.user.name, + email: device.member.user.email, + }, + source, + ...(provider ? { integrationProvider: provider } : {}), + complianceStatus, + daysSinceLastCheckIn: daysSinceCheckIn(device.lastCheckIn), + hasActiveAgentSession: + source === 'device_agent' && + !!device.agentSession && + device.agentSession.expiresAt.getTime() > Date.now(), + }; + }); return NextResponse.json({ data }); } diff --git a/tools/device-sync-definitions/README.md b/tools/device-sync-definitions/README.md new file mode 100644 index 000000000..67b20c78f --- /dev/null +++ b/tools/device-sync-definitions/README.md @@ -0,0 +1,60 @@ +# device-sync-definitions + +Version-controlled `deviceSyncDefinition` DSL for dynamic (DB-backed) integrations, +plus an apply script and an offline test harness. + +## Why this exists + +Device sync for a dynamic integration is a `deviceSyncDefinition` (a DSL with +`code` steps) stored on the integration's row **in the database**, authored via +the internal API. The public `integrations-catalog/` is an export-only mirror +that **strips** sync DSL, so without this folder these definitions would be +un-versioned and unrecoverable. Keep the source of truth here; apply it to the DB. + +> Code-manifest integrations (google-workspace, rippling, aws, azure, gcp, +> github, vercel, aikido) are **not** authored this way — they live in +> `packages/integration-platform/src/manifests/`. The device-sync execution path +> currently requires a DB `deviceSyncDefinition`, so this tool only targets +> dynamic integrations (e.g. intune, jumpcloud). + +## Contract + +Each code step must populate `scope.devices` with `SyncDevice` objects +(`packages/integration-platform/src/dsl/types.ts`): + +- required: `name`, `platform` (`macos|windows|linux`), `userEmail`, `status` (`active|inactive`) +- optional: `serialNumber`, `externalId`, `hostname`, `osVersion`, `hardwareModel` + +The import (`GenericDeviceSyncService`) matches each device to a member by +lowercased `userEmail`; a device with no resolvable email is silently skipped. +`isDirectorySource` stays `false` (import-only; never deletes). + +## Test (offline, no token needed) + +```bash +node tools/device-sync-definitions/test.mjs +``` + +Runs each `code` string exactly as the interpreter does against canned API +responses and validates the output against the SyncDevice contract. + +## Apply (needs the internal token) + +Same env-var convention as `tools/integrations-catalog-sync`. + +```bash +export COMPAI_INTERNAL_API_BASE="https://api.staging.trycomp.ai/v1/internal" +export COMPAI_INTERNAL_TOKEN="" + +# dry run — prints the plan, writes nothing +node tools/device-sync-definitions/apply.mjs intune + +# apply for real (re-sends existing checks verbatim, adds device_sync + the definition) +node tools/device-sync-definitions/apply.mjs intune --yes +``` + +Run against **staging** first, connect the provider, set it as the org's device +sync provider (People → Devices), Sync now, and confirm rows appear. Then prod. + +The selector and daily scheduler pick up the new `device_sync` capability within +~60s (registry refresh). diff --git a/tools/device-sync-definitions/apply.mjs b/tools/device-sync-definitions/apply.mjs new file mode 100644 index 000000000..ba24f131b --- /dev/null +++ b/tools/device-sync-definitions/apply.mjs @@ -0,0 +1,148 @@ +// Applies a device_sync definition to a dynamic integration's DB row via the +// internal API. The DSL bodies live in intune.mjs / jumpcloud.mjs (version +// controlled); this script merges one in without disturbing existing checks. +// +// Usage: +// export COMPAI_INTERNAL_API_BASE="https://api.staging.trycomp.ai/v1/internal" +// export COMPAI_INTERNAL_TOKEN="" +// node tools/device-sync-definitions/apply.mjs intune # dry run (prints plan) +// node tools/device-sync-definitions/apply.mjs intune --yes # actually write +// +// Same env-var convention as tools/integrations-catalog-sync. Run against +// STAGING first, smoke-test a real connection, then prod. +// +// Safety: the internal PUT is a FULL upsert that DELETES checks not present in +// the body, so this script fetches the full integration (with every check) and +// re-sends them verbatim, only ADDING the device_sync capability + definition. + +import { deviceSyncDefinition as intuneDef } from './intune.mjs'; +import { deviceSyncDefinition as jumpcloudDef } from './jumpcloud.mjs'; + +const DEFS = { intune: intuneDef, jumpcloud: jumpcloudDef }; + +const slug = process.argv[2]; +const confirm = process.argv.includes('--yes'); + +const API_BASE = process.env.COMPAI_INTERNAL_API_BASE; +const TOKEN = process.env.COMPAI_INTERNAL_TOKEN; + +function die(msg) { + console.error(`ERROR: ${msg}`); + process.exit(1); +} + +if (!slug || !DEFS[slug]) { + die(`pass a slug: one of ${Object.keys(DEFS).join(', ')}`); +} +if (!API_BASE) die('COMPAI_INTERNAL_API_BASE env var is required'); +if (!TOKEN) die('COMPAI_INTERNAL_TOKEN env var is required'); + +const HEADERS = { 'x-internal-token': TOKEN, 'Content-Type': 'application/json' }; + +async function api(path, init = {}) { + const res = await fetch(`${API_BASE}${path}`, { + ...init, + headers: { ...HEADERS, ...(init.headers || {}) }, + }); + const text = await res.text(); + let json; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = text; + } + if (!res.ok) { + die(`${init.method || 'GET'} ${path} → ${res.status}: ${text.slice(0, 500)}`); + } + return json; +} + +// Strip DB-only/null fields so the body matches DynamicIntegrationDefinitionSchema. +function mapCheck(c) { + const out = { + checkSlug: c.checkSlug, + name: c.name, + description: c.description, + definition: c.definition, + }; + if (c.taskMapping != null) out.taskMapping = c.taskMapping; + if (c.defaultSeverity != null) out.defaultSeverity = c.defaultSeverity; + if (c.service != null) out.service = c.service; + if (Array.isArray(c.variables)) out.variables = c.variables; + if (typeof c.isEnabled === 'boolean') out.isEnabled = c.isEnabled; + if (typeof c.sortOrder === 'number') out.sortOrder = c.sortOrder; + return out; +} + +async function main() { + console.log(`Looking up "${slug}" at ${API_BASE} ...`); + const list = await api('/dynamic-integrations'); + const found = (Array.isArray(list) ? list : []).find((i) => i.slug === slug); + if (!found) { + die(`No dynamic integration with slug "${slug}" found. (Is it a code manifest, or not authored yet?)`); + } + + const integ = await api(`/dynamic-integrations/${found.id}`); + const currentCaps = Array.isArray(integ.capabilities) ? integ.capabilities : ['checks']; + const checks = Array.isArray(integ.checks) ? integ.checks : []; + + const nextCaps = Array.from(new Set([...currentCaps, 'device_sync'])); + + const body = { + slug: integ.slug, + name: integ.name, + description: integ.description, + category: integ.category, + logoUrl: integ.logoUrl, + authConfig: integ.authConfig, + capabilities: nextCaps, + checks: checks.map(mapCheck), + deviceSyncDefinition: DEFS[slug], + }; + if (integ.docsUrl) body.docsUrl = integ.docsUrl; + if (integ.baseUrl) body.baseUrl = integ.baseUrl; + if (integ.defaultHeaders) body.defaultHeaders = integ.defaultHeaders; + if (typeof integ.supportsMultipleConnections === 'boolean') { + body.supportsMultipleConnections = integ.supportsMultipleConnections; + } + if (integ.syncDefinition) body.syncDefinition = integ.syncDefinition; + if (Array.isArray(integ.services) && integ.services.length > 0) { + body.services = integ.services; + } + + console.log('\nPlan:'); + console.log(` integration : ${integ.slug} (${found.id})`); + console.log(` capabilities: [${currentCaps.join(', ')}] → [${nextCaps.join(', ')}]`); + console.log(` checks kept : ${body.checks.length} (re-sent verbatim)`); + console.log(` deviceSync : ${DEFS[slug].steps.length} step(s), devicesPath="${DEFS[slug].devicesPath}", isDirectorySource=${DEFS[slug].isDirectorySource}`); + console.log(` syncDef kept: ${body.syncDefinition ? 'yes' : 'no'}`); + + if (!confirm) { + console.log('\nDry run only. Re-run with --yes to apply.'); + return; + } + + console.log('\nApplying (PUT) ...'); + const result = await api('/dynamic-integrations', { + method: 'PUT', + body: JSON.stringify(body), + }); + console.log(` upserted: ${result.slug} (${result.checksCount} checks)`); + + // Verify the write landed. The internal upsert rewrites the integration and + // re-creates checks in separate (non-transactional) steps, so also assert the + // check count is intact — a partial failure could otherwise drop checks. + const after = await api(`/dynamic-integrations/${found.id}`); + const afterCaps = Array.isArray(after.capabilities) ? after.capabilities : []; + const afterChecks = Array.isArray(after.checks) ? after.checks.length : 0; + if (!afterCaps.includes('device_sync') || !after.deviceSyncDefinition) { + die(`Verification failed: capabilities=${JSON.stringify(afterCaps)} hasDeviceSyncDefinition=${!!after.deviceSyncDefinition}`); + } + if (afterChecks !== body.checks.length) { + die(`Verification failed: expected ${body.checks.length} checks, found ${afterChecks} — the upsert may have partially applied. Re-run to restore.`); + } + console.log(` verified: device_sync capability + deviceSyncDefinition present, ${afterChecks} checks intact. ✅`); + console.log('\nDone. The selector/scheduler pick up the new capability within ~60s (registry refresh).'); +} + +main().catch((e) => die(e.message)); diff --git a/tools/device-sync-definitions/intune.mjs b/tools/device-sync-definitions/intune.mjs new file mode 100644 index 000000000..5794e1831 --- /dev/null +++ b/tools/device-sync-definitions/intune.mjs @@ -0,0 +1,112 @@ +// Microsoft Intune `device_sync` definition. +// +// This is the source of truth for the `deviceSyncDefinition` that gets authored +// into the Intune dynamic integration's DB row via the internal API (apply.mjs). +// It lives in-repo deliberately: the DB is the only live store for sync DSL and +// the public integrations-catalog export strips it, so without this file the +// definition would be un-versioned and unrecoverable. +// +// Contract (packages/integration-platform/src/dsl/types.ts → SyncDeviceSchema): +// required: name, platform (macos|windows|linux), userEmail, status (active|inactive) +// optional: serialNumber, externalId, hostname, osVersion, hardwareModel +// The import (GenericDeviceSyncService) matches each device to a member by +// lowercased userEmail; a device with no resolvable email is silently skipped. +// +// Auth: Intune is OAuth2, so the interpreter injects `ctx.accessToken` (a Graph +// bearer token). Scope DeviceManagementManagedDevices.Read.All is already +// configured on the connection. + +export const slug = 'intune'; + +// Executed by the DSL interpreter as `new AsyncFunction('ctx','scope', code)`. +// Must populate `scope.devices`. +export const code = ` +const token = ctx.accessToken; +if (!token) { + throw new Error('No Intune access token — reconnect the integration.'); +} + +// Retry transient transport errors and 5xx/429 so a single network blip on one +// page doesn't abort the whole (scheduled) sync. Auth/4xx are returned as-is. +async function fetchRetry(u, opts) { + for (let attempt = 1; ; attempt++) { + let r; + try { + r = await fetch(u, opts); + } catch (e) { + if (attempt >= 3) throw e; + await new Promise((res) => setTimeout(res, 400 * attempt)); + continue; + } + if ((r.status >= 500 || r.status === 429) && attempt < 3) { + await new Promise((res) => setTimeout(res, 400 * attempt)); + continue; + } + return r; + } +} + +const devices = []; +let url = 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$top=100'; +let pages = 0; + +while (url && pages < 200) { + pages++; + const res = await fetchRetry(url, { + headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' }, + }); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + throw new Error( + 'Intune authorization failed (' + res.status + '). Reconnect the integration or grant DeviceManagementManagedDevices.Read.All.', + ); + } + const body = await res.text(); + throw new Error('Intune Graph error ' + res.status + ': ' + body.slice(0, 300)); + } + + const data = await res.json(); + const list = Array.isArray(data.value) ? data.value : []; + + for (const d of list) { + // Platform: the schema is desktop-only. Drop iOS/Android/unknown. + const osRaw = String(d.operatingSystem || '').toLowerCase(); + let platform = null; + if (osRaw.includes('windows')) platform = 'windows'; + else if (osRaw.includes('mac') || osRaw.includes('os x')) platform = 'macos'; + else if (osRaw.includes('linux')) platform = 'linux'; + if (!platform) continue; + + // Owner email: emailAddress, fall back to userPrincipalName (on Entra/M365 + // tenants the UPN equals the user's email). No owner ⇒ import skips it. + const email = String(d.emailAddress || d.userPrincipalName || '').trim().toLowerCase(); + if (!email) continue; + + const serial = String(d.serialNumber || '').trim(); + const externalId = d.id ? String(d.id) : ''; + if (!serial && !externalId) continue; + + const name = String(d.deviceName || d.managedDeviceName || serial || externalId); + + const device = { name: name, platform: platform, userEmail: email, status: 'active' }; + if (serial) device.serialNumber = serial; + if (externalId) device.externalId = externalId; + if (d.osVersion) device.osVersion = String(d.osVersion); + if (d.model) device.hardwareModel = String(d.model); + if (d.deviceName) device.hostname = String(d.deviceName); + devices.push(device); + } + + url = data['@odata.nextLink'] || null; +} + +ctx.log('Intune device sync mapped ' + devices.length + ' devices'); +scope.devices = devices; +`; + +export const deviceSyncDefinition = { + steps: [{ type: 'code', code }], + devicesPath: 'devices', + isDirectorySource: false, +}; diff --git a/tools/device-sync-definitions/jumpcloud.mjs b/tools/device-sync-definitions/jumpcloud.mjs new file mode 100644 index 000000000..72e6764e0 --- /dev/null +++ b/tools/device-sync-definitions/jumpcloud.mjs @@ -0,0 +1,159 @@ +// JumpCloud `device_sync` definition. +// +// Source of truth for the deviceSyncDefinition authored into JumpCloud's DB row +// via the internal API (apply.mjs). See intune.mjs for why this lives in-repo. +// +// JumpCloud's catalog auth type is `custom`, so the interpreter injects NO auth +// header — the code reads the API key from ctx.credentials.api_key and sets the +// `x-api-key` header itself (mirroring the existing JumpCloud employee sync in +// apps/api/src/integration-platform/controllers/sync.controller.ts). +// +// JumpCloud devices ("systems") carry no owner email directly; ownership lives +// in the user→system bindings. We fetch systems + users, then per-user bindings +// to resolve each system's owner email (first bound user wins). A system with no +// bound user resolves to no email and is silently skipped by the import. + +export const slug = 'jumpcloud'; + +export const code = ` +const rawKey = ctx.credentials && ctx.credentials.api_key; +const apiKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; +if (!apiKey) { + throw new Error('JumpCloud API key not found — reconnect the integration.'); +} + +const headers = { 'x-api-key': apiKey, Accept: 'application/json' }; + +// Retry transient transport errors and 5xx/429 so one network blip doesn't +// abort the whole (scheduled) sync. Auth/4xx fall through to the error handling. +async function getJson(url) { + let res; + for (let attempt = 1; ; attempt++) { + try { + res = await fetch(url, { headers: headers }); + } catch (e) { + if (attempt >= 3) throw e; + await new Promise((r) => setTimeout(r, 400 * attempt)); + continue; + } + if ((res.status >= 500 || res.status === 429) && attempt < 3) { + await new Promise((r) => setTimeout(r, 400 * attempt)); + continue; + } + break; + } + if (!res.ok) { + if (res.status === 401) { + throw new Error('JumpCloud API key is invalid — reconnect the integration.'); + } + const body = await res.text(); + throw new Error('JumpCloud API error ' + res.status + ': ' + body.slice(0, 200)); + } + return res.json(); +} + +// 1. All systems (devices), paginated by limit/skip. +const systemsById = {}; +{ + const limit = 100; + let skip = 0; + let more = true; + while (more) { + const data = await getJson( + 'https://console.jumpcloud.com/api/systems?limit=' + limit + '&skip=' + skip, + ); + const results = (data && data.results) || []; + for (const s of results) systemsById[s._id] = s; + skip += results.length; + more = results.length === limit && skip < (data.totalCount || 0); + if (results.length === 0) more = false; + } +} + +// 2. All users, paginated. +const users = []; +{ + const limit = 100; + let skip = 0; + let more = true; + while (more) { + const data = await getJson( + 'https://console.jumpcloud.com/api/systemusers?limit=' + limit + '&skip=' + skip, + ); + const results = (data && data.results) || []; + users.push.apply(users, results); + skip += results.length; + more = results.length === limit && skip < (data.totalCount || 0); + if (results.length === 0) more = false; + } +} + +// 3. Resolve each system's owner email via user→system bindings (first wins). +const ownerEmailBySystemId = {}; +for (const user of users) { + const email = String(user.email || '').trim().toLowerCase(); + if (!email) continue; + let bindings; + try { + // limit=100 (the v2 max) so a user bound to many systems isn't truncated at + // the default page size of 10. + bindings = await getJson( + 'https://console.jumpcloud.com/api/v2/users/' + user._id + '/systems?limit=100', + ); + } catch (e) { + continue; // ignore per-user binding errors, keep going + } + if (!Array.isArray(bindings)) continue; + for (const b of bindings) { + if (b && b.id && !ownerEmailBySystemId[b.id]) { + ownerEmailBySystemId[b.id] = email; + } + } +} + +// 4. Emit a SyncDevice per system that has a resolvable owner. +function mapPlatform(os) { + const v = String(os || '').trim().toLowerCase(); + if (!v) return null; // os not reported yet — skip rather than mislabel as linux + if (v.includes('mac') || v.includes('os x') || v.includes('darwin')) return 'macos'; + if (v.includes('windows')) return 'windows'; + // JumpCloud manages only Mac/Windows/Linux, so any other non-empty os is Linux. + return 'linux'; +} + +const devices = []; +for (const id in systemsById) { + const s = systemsById[id]; + const email = ownerEmailBySystemId[id]; + if (!email) continue; + + const platform = mapPlatform(s.os); + if (!platform) continue; + + const serial = String(s.serialNumber || '').trim(); + const externalId = String(s._id || ''); + if (!serial && !externalId) continue; + + const name = String(s.displayName || s.hostname || serial || externalId); + const device = { + name: name, + platform: platform, + userEmail: email, + status: s.active === false ? 'inactive' : 'active', + externalId: externalId, + }; + if (serial) device.serialNumber = serial; + if (s.hostname) device.hostname = String(s.hostname); + if (s.version) device.osVersion = String(s.version); + devices.push(device); +} + +ctx.log('JumpCloud device sync mapped ' + devices.length + ' devices'); +scope.devices = devices; +`; + +export const deviceSyncDefinition = { + steps: [{ type: 'code', code }], + devicesPath: 'devices', + isDirectorySource: false, +}; diff --git a/tools/device-sync-definitions/test.mjs b/tools/device-sync-definitions/test.mjs new file mode 100644 index 000000000..1865ea1cb --- /dev/null +++ b/tools/device-sync-definitions/test.mjs @@ -0,0 +1,270 @@ +// Offline mock harness for the Intune + JumpCloud device_sync code steps. +// Runs each `code` string exactly as the DSL interpreter does +// (new AsyncFunction('ctx','scope', code)) against canned API responses, then +// validates the output against the SyncDeviceSchema contract (invalid dropped). +// +// Run: node tools/device-sync-definitions/test.mjs +// +// This proves correctness without the live internal API or a real connection. + +import assert from 'node:assert'; +import { code as intuneCode } from './intune.mjs'; +import { code as jumpcloudCode } from './jumpcloud.mjs'; + +let failures = 0; +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + } catch (e) { + failures++; + console.error(` ✗ ${name}\n ${e.message}`); + } +} + +// ---- helpers ------------------------------------------------------------- +function resp(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + }; +} + +// Mirrors SyncDeviceSchema (packages/integration-platform/src/dsl/types.ts): +// the interpreter drops items that fail this, so the harness does too. +const PLATFORMS = ['macos', 'windows', 'linux']; +const STATUSES = ['active', 'inactive']; +function validate(devices) { + assert(Array.isArray(devices), 'scope.devices must be an array'); + return devices.filter( + (d) => + d && + typeof d.name === 'string' && + d.name.length > 0 && + PLATFORMS.includes(d.platform) && + typeof d.userEmail === 'string' && + d.userEmail.length > 0 && + STATUSES.includes(d.status), + ); +} + +async function runCode(code, ctx) { + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const fn = new AsyncFunction('ctx', 'scope', code); + const scope = {}; + await fn(ctx, scope); + return scope.devices; +} + +function makeCtx({ accessToken, credentials, fetchImpl }) { + globalThis.fetch = fetchImpl; + return { + accessToken, + credentials, + log: () => {}, + warn: () => {}, + error: () => {}, + }; +} + +async function expectThrows(promise, match) { + try { + await promise; + } catch (e) { + assert(match.test(e.message), `expected error matching ${match}, got: ${e.message}`); + return; + } + throw new Error('expected the code to throw, but it resolved'); +} + +// ========================================================================= +// Intune +// ========================================================================= +console.log('Intune device_sync:'); + +await (async () => { + const page1 = { + value: [ + { id: 'i1', deviceName: 'WIN-1', operatingSystem: 'Windows', osVersion: '10.0', model: 'Surface', emailAddress: 'Win@CO.com', serialNumber: 'W1' }, + { id: 'i2', deviceName: 'MAC-1', operatingSystem: 'macOS', emailAddress: '', userPrincipalName: 'Mac@CO.com', serialNumber: 'M1' }, + { id: 'i3', deviceName: 'IPHONE', operatingSystem: 'iOS', emailAddress: 'phone@co.com' }, + { id: 'i4', deviceName: 'KIOSK', operatingSystem: 'Windows', emailAddress: '', userPrincipalName: '' }, + ], + '@odata.nextLink': 'PAGE2', + }; + const page2 = { + value: [ + { id: 'i5', deviceName: 'LNX-1', operatingSystem: 'Linux', emailAddress: 'lnx@co.com' }, + ], + }; + const fetchImpl = async (url) => { + if (url === 'PAGE2') return resp(200, page2); + if (String(url).includes('managedDevices')) return resp(200, page1); + throw new Error('unexpected url ' + url); + }; + const ctx = makeCtx({ accessToken: 'tok', fetchImpl }); + const raw = await runCode(intuneCode, ctx); + const devices = validate(raw); + + test('paginates and maps Windows/macOS/Linux, drops iOS + no-email', () => { + assert.equal(devices.length, 3); + const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); + assert.equal(byId.i1.platform, 'windows'); + assert.equal(byId.i2.platform, 'macos'); + assert.equal(byId.i5.platform, 'linux'); + assert(!devices.some((d) => d.externalId === 'i3'), 'iOS must be dropped'); + assert(!devices.some((d) => d.externalId === 'i4'), 'no-email must be dropped'); + }); + + test('lowercases owner email and falls back to userPrincipalName', () => { + const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); + assert.equal(byId.i1.userEmail, 'win@co.com'); + assert.equal(byId.i2.userEmail, 'mac@co.com'); // UPN fallback + }); + + test('carries serial, externalId, osVersion, hardwareModel', () => { + const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); + assert.equal(byId.i1.serialNumber, 'W1'); + assert.equal(byId.i1.osVersion, '10.0'); + assert.equal(byId.i1.hardwareModel, 'Surface'); + }); +})(); + +await (async () => { + const ctx = makeCtx({ accessToken: 'tok', fetchImpl: async () => resp(401, 'Unauthorized') }); + test('throws a clear error on 401', async () => {}); + await expectThrows(runCode(intuneCode, ctx), /authorization failed/i); +})(); + +await (async () => { + const ctx = makeCtx({ accessToken: 'tok', fetchImpl: async () => resp(200, { value: [] }) }); + const raw = await runCode(intuneCode, ctx); + test('produces an empty list when no devices', () => { + assert.deepEqual(validate(raw), []); + }); +})(); + +await (async () => { + let calls = 0; + const fetchImpl = async () => { + calls++; + if (calls === 1) return resp(503, 'busy'); + return resp(200, { + value: [{ id: 'i9', deviceName: 'R', operatingSystem: 'Windows', emailAddress: 'r@co.com', serialNumber: 'R1' }], + }); + }; + const ctx = makeCtx({ accessToken: 'tok', fetchImpl }); + const raw = await runCode(intuneCode, ctx); + test('retries a transient 503 then succeeds', () => { + assert.equal(validate(raw).length, 1); + assert(calls >= 2, 'should have retried after the 503'); + }); +})(); + +await (async () => { + const ctx = makeCtx({ accessToken: undefined, fetchImpl: async () => resp(200, { value: [] }) }); + test('throws when there is no access token', async () => {}); + await expectThrows(runCode(intuneCode, ctx), /no intune access token/i); +})(); + +// ========================================================================= +// JumpCloud +// ========================================================================= +console.log('JumpCloud device_sync:'); + +function jumpcloudFetch({ systems, users, bindings, failSystemsStatus }) { + return async (url, opts) => { + const u = String(url); + assert(opts && opts.headers && opts.headers['x-api-key'] === 'jc-key', 'must send x-api-key'); + if (u.includes('/api/systems')) { + if (failSystemsStatus) return resp(failSystemsStatus, 'nope'); + return resp(200, { totalCount: systems.length, results: systems }); + } + if (u.includes('/api/systemusers')) { + return resp(200, { totalCount: users.length, results: users }); + } + const m = u.match(/\/api\/v2\/users\/([^/]+)\/systems/); + if (m) return resp(200, bindings[m[1]] || []); + throw new Error('unexpected url ' + u); + }; +} + +await (async () => { + const systems = [ + { _id: 's1', displayName: 'Alice Mac', os: 'Mac OS X', serialNumber: 'SN1', hostname: 'alice-mac', version: '14.1' }, + { _id: 's2', displayName: 'Bob Win', os: 'Windows', serialNumber: 'SN2' }, + { _id: 's3', displayName: 'Orphan', os: 'Ubuntu', serialNumber: 'SN3' }, + { _id: 's4', displayName: 'Bob Linux', os: 'Ubuntu', serialNumber: 'SN4', active: false }, + { _id: 's5', displayName: 'Enrolling', os: '', serialNumber: 'SN5' }, + ]; + const users = [ + { _id: 'u1', email: 'Alice@CO.com' }, + { _id: 'u2', email: 'bob@co.com' }, + { _id: 'u3', email: '' }, + { _id: 'u4', email: 'dave@co.com' }, + ]; + const bindings = { + u1: [{ id: 's1', type: 'system' }], + u2: [{ id: 's2', type: 'system' }, { id: 's4', type: 'system' }], + u3: [{ id: 's3', type: 'system' }], + u4: [{ id: 's5', type: 'system' }], + }; + const ctx = makeCtx({ + credentials: { api_key: 'jc-key' }, + fetchImpl: jumpcloudFetch({ systems, users, bindings }), + }); + const raw = await runCode(jumpcloudCode, ctx); + const devices = validate(raw); + + test('joins systems→users→bindings and skips systems with no owner', () => { + assert.equal(devices.length, 3); // s1, s2, s4 (s3 no email, s5 no os) + assert(!devices.some((d) => d.externalId === 's3'), 's3 has no real owner → skipped'); + }); + + test('skips a system whose os is not yet reported (no mislabel as linux)', () => { + assert(!devices.some((d) => d.externalId === 's5'), 's5 has empty os → skipped'); + }); + + test('maps Mac/Windows/Linux and lowercases owner email', () => { + const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); + assert.equal(byId.s1.platform, 'macos'); + assert.equal(byId.s1.userEmail, 'alice@co.com'); + assert.equal(byId.s2.platform, 'windows'); + assert.equal(byId.s4.platform, 'linux'); + assert.equal(byId.s4.userEmail, 'bob@co.com'); + }); + + test('reflects system.active in status and carries serial/hostname/osVersion', () => { + const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); + assert.equal(byId.s1.status, 'active'); + assert.equal(byId.s4.status, 'inactive'); + assert.equal(byId.s1.serialNumber, 'SN1'); + assert.equal(byId.s1.hostname, 'alice-mac'); + assert.equal(byId.s1.osVersion, '14.1'); + }); +})(); + +await (async () => { + const ctx = makeCtx({ + credentials: { api_key: 'jc-key' }, + fetchImpl: jumpcloudFetch({ systems: [], users: [], bindings: {}, failSystemsStatus: 401 }), + }); + test('throws a clear error on 401', async () => {}); + await expectThrows(runCode(jumpcloudCode, ctx), /api key is invalid/i); +})(); + +await (async () => { + const ctx = makeCtx({ credentials: {}, fetchImpl: async () => resp(200, {}) }); + test('throws when API key is missing', async () => {}); + await expectThrows(runCode(jumpcloudCode, ctx), /api key not found/i); +})(); + +// ========================================================================= +console.log(''); +if (failures > 0) { + console.error(`${failures} test(s) failed`); + process.exit(1); +} +console.log('All device_sync definition tests passed.'); From dda64db1c96ddf5c04af8582fd0fa45d20dd71d5 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 30 Jun 2026 15:11:39 -0400 Subject: [PATCH 05/18] fix(devices): address cubic review on device-import display + sync definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/device-source.ts: shared sourceLabel + isComplianceTracked (used by the table and CSV so they can't drift); isComplianceTracked now treats Fleet as tracked — only integration imports are "Not tracked" - DeviceDetails: show live online/offline status only for agent devices - DeviceAgentDevicesList: keep the source filter visible while a non-default filter is active so users can't get stuck on an empty table - DeviceListCells: aria-label on the device-actions menu trigger - intune.mjs: fail loudly if pagination exceeds the page cap instead of importing a partial fleet as success - jumpcloud.mjs: stable sort on the systems + users pagination to avoid missing/duplicating records when data shifts mid-sync - test.mjs: make the harness await async tests so throw-cases are reliably caught and counted - devices-csv.test.ts: regression test proving an integration provider name can't reopen CSV formula injection (existing escaping already neutralizes it) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015iDU78gxNH9Wp9sex1BDLS --- .../components/DeviceAgentDevicesList.tsx | 5 ++- .../devices/components/DeviceDetails.tsx | 12 ++--- .../devices/components/DeviceListCells.tsx | 16 +------ .../people/devices/lib/device-source.ts | 22 ++++++++++ .../people/devices/lib/devices-csv.test.ts | 15 +++++++ .../[orgId]/people/devices/lib/devices-csv.ts | 9 +--- tools/device-sync-definitions/intune.mjs | 14 +++++- tools/device-sync-definitions/jumpcloud.mjs | 6 ++- tools/device-sync-definitions/test.mjs | 44 ++++++++++--------- 9 files changed, 92 insertions(+), 51 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx index b4055f46b..82a16b88c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx @@ -29,7 +29,8 @@ import { devicesCsvFilename, downloadDevicesCsv, } from '../lib/devices-csv'; -import { DeviceTableRow, sourceLabel } from './DeviceListCells'; +import { DeviceTableRow } from './DeviceListCells'; +import { sourceLabel } from '../lib/device-source'; import { DeviceDetails } from './DeviceDetails'; import { RemoveDeviceAlert } from '../../all/components/RemoveDeviceAlert'; @@ -142,7 +143,7 @@ export const DeviceAgentDevicesList = ({ />
- {sourceOptions.length > 1 && ( + {(sourceOptions.length > 1 || sourceFilter !== 'all') && ( - )} - /> - {errors.partyName?.message} - - - ( - - )} - /> - -
+ {/* No "Linked party ID" input: the party link is system-managed and only + set for rows derived from the Interested Parties Register. */} + + ( + + )} + /> + {errors.partyName?.message} +
{ ); expect(onSave).not.toHaveBeenCalled(); }); + + it('does not surface a raw "Linked party ID" input', () => { + render( + , + ); + + fireEvent.click(screen.getByLabelText('Edit requirement')); + + expect(screen.queryByLabelText('Requirement party ID')).not.toBeInTheDocument(); + expect(screen.queryByText('Linked party ID (optional)')).not.toBeInTheDocument(); + }); + + it('carries the system-managed party link through on save without exposing it', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.click(screen.getByLabelText('Edit requirement')); + fireEvent.change(screen.getByLabelText('Requirement description'), { + target: { value: 'Updated requirement text' }, + }); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + interestedPartyId: 'isms_ip_abc123', + requirement: 'Updated requirement text', + }), + ); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsRow.tsx index f7fcecc81..955ef5549 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsRow.tsx @@ -116,33 +116,22 @@ export function RequirementsRow({ requirement, canEdit, onSave, onDelete }: Requ headerEnd={actions} > -
- - - ( - - )} - /> - {errors.partyName?.message} - - - + {/* interestedPartyId is a system-managed link (set when the row is + derived from an Interested Parties Register entry). It rides along + in the form's default values and is carried through on save, but is + never surfaced as a raw-id input. */} + + ( - + )} /> - -
+ {errors.partyName?.message} +
+ (
+ {backHref ? ISMS : null}

{`${clause} ${title}`}

{actions}
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsSourceBadge.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsSourceBadge.test.tsx new file mode 100644 index 000000000..32af7bc0c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsSourceBadge.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ismsDesignSystemMock } from '../__test-helpers__/dsMocks'; + +vi.mock('@trycompai/design-system', () => ismsDesignSystemMock()); + +import { IsmsSourceBadge } from './IsmsSourceBadge'; + +describe('IsmsSourceBadge', () => { + it('shows the party name for a party-derived row, never the raw record id', () => { + render(); + + expect(screen.getByText('Employees / workforce')).toBeInTheDocument(); + // The raw "party:" provenance prefix must never leak into the label. + expect(screen.queryByText(/^party:/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^Party /)).not.toBeInTheDocument(); + }); + + it('falls back to "Interested party" when the party name is blank', () => { + render(); + + expect(screen.getByText('Interested party')).toBeInTheDocument(); + }); + + it('shows the framework name for framework provenance', () => { + render(); + + expect(screen.getByText('ISO 27001')).toBeInTheDocument(); + }); + + it('maps known provenance keys to friendly labels', () => { + render(); + + expect(screen.getByText('Vendor register')).toBeInTheDocument(); + }); + + it('labels manual rows "Manual"', () => { + render(); + + expect(screen.getByText('Manual')).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsSourceBadge.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsSourceBadge.tsx index b17b6bf89..95fa3cc75 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsSourceBadge.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/shared/IsmsSourceBadge.tsx @@ -26,6 +26,12 @@ function humanizeProvenance(derivedFrom?: string | null): string { const name = derivedFrom.slice('framework:'.length).trim(); return name || 'Framework'; } + // Requirement rows are derived per interested party; the suffix is the party + // name, shown verbatim (never the raw record id). + if (derivedFrom.startsWith('party:')) { + const name = derivedFrom.slice('party:'.length).trim(); + return name || 'Interested party'; + } if (derivedFrom.startsWith('wizard:')) return 'Setup wizard'; const mapped = PROVENANCE_LABELS[derivedFrom]; if (mapped) return mapped; From 1ef786ecdfb8810f8b59eb9ce98928762b33851d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 30 Jun 2026 15:52:26 -0400 Subject: [PATCH 15/18] chore(devices): remove tools/device-sync-definitions; keep device sync DB-only The Intune + JumpCloud deviceSyncDefinitions are authored and stored in the DynamicIntegration DB rows (via the internal dynamic-integrations endpoint), exactly like the employee syncDefinition and the checks. The in-repo source copies + apply script + tests duplicated that DB state and broke convention (a drift risk), so remove them. The live staging/prod definitions are unchanged and remain editable through the builder/internal endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015iDU78gxNH9Wp9sex1BDLS --- tools/device-sync-definitions/README.md | 60 ------ tools/device-sync-definitions/apply.mjs | 148 -------------- tools/device-sync-definitions/harness.mjs | 87 --------- tools/device-sync-definitions/intune.mjs | 124 ------------ tools/device-sync-definitions/intune.test.mjs | 93 --------- tools/device-sync-definitions/jumpcloud.mjs | 166 ---------------- .../jumpcloud.test.mjs | 182 ------------------ tools/device-sync-definitions/test.mjs | 15 -- 8 files changed, 875 deletions(-) delete mode 100644 tools/device-sync-definitions/README.md delete mode 100644 tools/device-sync-definitions/apply.mjs delete mode 100644 tools/device-sync-definitions/harness.mjs delete mode 100644 tools/device-sync-definitions/intune.mjs delete mode 100644 tools/device-sync-definitions/intune.test.mjs delete mode 100644 tools/device-sync-definitions/jumpcloud.mjs delete mode 100644 tools/device-sync-definitions/jumpcloud.test.mjs delete mode 100644 tools/device-sync-definitions/test.mjs diff --git a/tools/device-sync-definitions/README.md b/tools/device-sync-definitions/README.md deleted file mode 100644 index 67b20c78f..000000000 --- a/tools/device-sync-definitions/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# device-sync-definitions - -Version-controlled `deviceSyncDefinition` DSL for dynamic (DB-backed) integrations, -plus an apply script and an offline test harness. - -## Why this exists - -Device sync for a dynamic integration is a `deviceSyncDefinition` (a DSL with -`code` steps) stored on the integration's row **in the database**, authored via -the internal API. The public `integrations-catalog/` is an export-only mirror -that **strips** sync DSL, so without this folder these definitions would be -un-versioned and unrecoverable. Keep the source of truth here; apply it to the DB. - -> Code-manifest integrations (google-workspace, rippling, aws, azure, gcp, -> github, vercel, aikido) are **not** authored this way — they live in -> `packages/integration-platform/src/manifests/`. The device-sync execution path -> currently requires a DB `deviceSyncDefinition`, so this tool only targets -> dynamic integrations (e.g. intune, jumpcloud). - -## Contract - -Each code step must populate `scope.devices` with `SyncDevice` objects -(`packages/integration-platform/src/dsl/types.ts`): - -- required: `name`, `platform` (`macos|windows|linux`), `userEmail`, `status` (`active|inactive`) -- optional: `serialNumber`, `externalId`, `hostname`, `osVersion`, `hardwareModel` - -The import (`GenericDeviceSyncService`) matches each device to a member by -lowercased `userEmail`; a device with no resolvable email is silently skipped. -`isDirectorySource` stays `false` (import-only; never deletes). - -## Test (offline, no token needed) - -```bash -node tools/device-sync-definitions/test.mjs -``` - -Runs each `code` string exactly as the interpreter does against canned API -responses and validates the output against the SyncDevice contract. - -## Apply (needs the internal token) - -Same env-var convention as `tools/integrations-catalog-sync`. - -```bash -export COMPAI_INTERNAL_API_BASE="https://api.staging.trycomp.ai/v1/internal" -export COMPAI_INTERNAL_TOKEN="" - -# dry run — prints the plan, writes nothing -node tools/device-sync-definitions/apply.mjs intune - -# apply for real (re-sends existing checks verbatim, adds device_sync + the definition) -node tools/device-sync-definitions/apply.mjs intune --yes -``` - -Run against **staging** first, connect the provider, set it as the org's device -sync provider (People → Devices), Sync now, and confirm rows appear. Then prod. - -The selector and daily scheduler pick up the new `device_sync` capability within -~60s (registry refresh). diff --git a/tools/device-sync-definitions/apply.mjs b/tools/device-sync-definitions/apply.mjs deleted file mode 100644 index ba24f131b..000000000 --- a/tools/device-sync-definitions/apply.mjs +++ /dev/null @@ -1,148 +0,0 @@ -// Applies a device_sync definition to a dynamic integration's DB row via the -// internal API. The DSL bodies live in intune.mjs / jumpcloud.mjs (version -// controlled); this script merges one in without disturbing existing checks. -// -// Usage: -// export COMPAI_INTERNAL_API_BASE="https://api.staging.trycomp.ai/v1/internal" -// export COMPAI_INTERNAL_TOKEN="" -// node tools/device-sync-definitions/apply.mjs intune # dry run (prints plan) -// node tools/device-sync-definitions/apply.mjs intune --yes # actually write -// -// Same env-var convention as tools/integrations-catalog-sync. Run against -// STAGING first, smoke-test a real connection, then prod. -// -// Safety: the internal PUT is a FULL upsert that DELETES checks not present in -// the body, so this script fetches the full integration (with every check) and -// re-sends them verbatim, only ADDING the device_sync capability + definition. - -import { deviceSyncDefinition as intuneDef } from './intune.mjs'; -import { deviceSyncDefinition as jumpcloudDef } from './jumpcloud.mjs'; - -const DEFS = { intune: intuneDef, jumpcloud: jumpcloudDef }; - -const slug = process.argv[2]; -const confirm = process.argv.includes('--yes'); - -const API_BASE = process.env.COMPAI_INTERNAL_API_BASE; -const TOKEN = process.env.COMPAI_INTERNAL_TOKEN; - -function die(msg) { - console.error(`ERROR: ${msg}`); - process.exit(1); -} - -if (!slug || !DEFS[slug]) { - die(`pass a slug: one of ${Object.keys(DEFS).join(', ')}`); -} -if (!API_BASE) die('COMPAI_INTERNAL_API_BASE env var is required'); -if (!TOKEN) die('COMPAI_INTERNAL_TOKEN env var is required'); - -const HEADERS = { 'x-internal-token': TOKEN, 'Content-Type': 'application/json' }; - -async function api(path, init = {}) { - const res = await fetch(`${API_BASE}${path}`, { - ...init, - headers: { ...HEADERS, ...(init.headers || {}) }, - }); - const text = await res.text(); - let json; - try { - json = text ? JSON.parse(text) : null; - } catch { - json = text; - } - if (!res.ok) { - die(`${init.method || 'GET'} ${path} → ${res.status}: ${text.slice(0, 500)}`); - } - return json; -} - -// Strip DB-only/null fields so the body matches DynamicIntegrationDefinitionSchema. -function mapCheck(c) { - const out = { - checkSlug: c.checkSlug, - name: c.name, - description: c.description, - definition: c.definition, - }; - if (c.taskMapping != null) out.taskMapping = c.taskMapping; - if (c.defaultSeverity != null) out.defaultSeverity = c.defaultSeverity; - if (c.service != null) out.service = c.service; - if (Array.isArray(c.variables)) out.variables = c.variables; - if (typeof c.isEnabled === 'boolean') out.isEnabled = c.isEnabled; - if (typeof c.sortOrder === 'number') out.sortOrder = c.sortOrder; - return out; -} - -async function main() { - console.log(`Looking up "${slug}" at ${API_BASE} ...`); - const list = await api('/dynamic-integrations'); - const found = (Array.isArray(list) ? list : []).find((i) => i.slug === slug); - if (!found) { - die(`No dynamic integration with slug "${slug}" found. (Is it a code manifest, or not authored yet?)`); - } - - const integ = await api(`/dynamic-integrations/${found.id}`); - const currentCaps = Array.isArray(integ.capabilities) ? integ.capabilities : ['checks']; - const checks = Array.isArray(integ.checks) ? integ.checks : []; - - const nextCaps = Array.from(new Set([...currentCaps, 'device_sync'])); - - const body = { - slug: integ.slug, - name: integ.name, - description: integ.description, - category: integ.category, - logoUrl: integ.logoUrl, - authConfig: integ.authConfig, - capabilities: nextCaps, - checks: checks.map(mapCheck), - deviceSyncDefinition: DEFS[slug], - }; - if (integ.docsUrl) body.docsUrl = integ.docsUrl; - if (integ.baseUrl) body.baseUrl = integ.baseUrl; - if (integ.defaultHeaders) body.defaultHeaders = integ.defaultHeaders; - if (typeof integ.supportsMultipleConnections === 'boolean') { - body.supportsMultipleConnections = integ.supportsMultipleConnections; - } - if (integ.syncDefinition) body.syncDefinition = integ.syncDefinition; - if (Array.isArray(integ.services) && integ.services.length > 0) { - body.services = integ.services; - } - - console.log('\nPlan:'); - console.log(` integration : ${integ.slug} (${found.id})`); - console.log(` capabilities: [${currentCaps.join(', ')}] → [${nextCaps.join(', ')}]`); - console.log(` checks kept : ${body.checks.length} (re-sent verbatim)`); - console.log(` deviceSync : ${DEFS[slug].steps.length} step(s), devicesPath="${DEFS[slug].devicesPath}", isDirectorySource=${DEFS[slug].isDirectorySource}`); - console.log(` syncDef kept: ${body.syncDefinition ? 'yes' : 'no'}`); - - if (!confirm) { - console.log('\nDry run only. Re-run with --yes to apply.'); - return; - } - - console.log('\nApplying (PUT) ...'); - const result = await api('/dynamic-integrations', { - method: 'PUT', - body: JSON.stringify(body), - }); - console.log(` upserted: ${result.slug} (${result.checksCount} checks)`); - - // Verify the write landed. The internal upsert rewrites the integration and - // re-creates checks in separate (non-transactional) steps, so also assert the - // check count is intact — a partial failure could otherwise drop checks. - const after = await api(`/dynamic-integrations/${found.id}`); - const afterCaps = Array.isArray(after.capabilities) ? after.capabilities : []; - const afterChecks = Array.isArray(after.checks) ? after.checks.length : 0; - if (!afterCaps.includes('device_sync') || !after.deviceSyncDefinition) { - die(`Verification failed: capabilities=${JSON.stringify(afterCaps)} hasDeviceSyncDefinition=${!!after.deviceSyncDefinition}`); - } - if (afterChecks !== body.checks.length) { - die(`Verification failed: expected ${body.checks.length} checks, found ${afterChecks} — the upsert may have partially applied. Re-run to restore.`); - } - console.log(` verified: device_sync capability + deviceSyncDefinition present, ${afterChecks} checks intact. ✅`); - console.log('\nDone. The selector/scheduler pick up the new capability within ~60s (registry refresh).'); -} - -main().catch((e) => die(e.message)); diff --git a/tools/device-sync-definitions/harness.mjs b/tools/device-sync-definitions/harness.mjs deleted file mode 100644 index a88da1a72..000000000 --- a/tools/device-sync-definitions/harness.mjs +++ /dev/null @@ -1,87 +0,0 @@ -// Shared test harness for the device_sync definition tests. -// Runs each `code` string exactly as the DSL interpreter does -// (new AsyncFunction('ctx','scope', code)) and validates output against the -// SyncDeviceSchema contract. Imported by intune.test.mjs / jumpcloud.test.mjs. - -import assert from 'node:assert'; - -let failures = 0; - -// Async-aware: awaits the callback so async assertions (incl. expectThrows) are -// reliably caught and counted before report() runs. -export async function test(name, fn) { - try { - await fn(); - console.log(` ✓ ${name}`); - } catch (e) { - failures++; - console.error(` ✗ ${name}\n ${e.message}`); - } -} - -export function report() { - console.log(''); - if (failures > 0) { - console.error(`${failures} test(s) failed`); - process.exit(1); - } - console.log('All device_sync definition tests passed.'); -} - -export function resp(status, body) { - return { - ok: status >= 200 && status < 300, - status, - json: async () => body, - text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), - }; -} - -// Mirrors SyncDeviceSchema (packages/integration-platform/src/dsl/types.ts): -// the interpreter drops items that fail this, so the harness does too. -const PLATFORMS = ['macos', 'windows', 'linux']; -const STATUSES = ['active', 'inactive']; -export function validate(devices) { - assert(Array.isArray(devices), 'scope.devices must be an array'); - return devices.filter( - (d) => - d && - typeof d.name === 'string' && - d.name.length > 0 && - PLATFORMS.includes(d.platform) && - typeof d.userEmail === 'string' && - d.userEmail.length > 0 && - STATUSES.includes(d.status), - ); -} - -export async function runCode(code, ctx) { - const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; - const fn = new AsyncFunction('ctx', 'scope', code); - const scope = {}; - await fn(ctx, scope); - return scope.devices; -} - -export function makeCtx({ accessToken, credentials, fetchImpl }) { - globalThis.fetch = fetchImpl; - return { - accessToken, - credentials, - log: () => {}, - warn: () => {}, - error: () => {}, - }; -} - -// Rejects (so the enclosing test() counts a failure) unless `promise` throws -// with a message matching `match`. -export async function expectThrows(promise, match) { - try { - await promise; - } catch (e) { - assert(match.test(e.message), `expected error matching ${match}, got: ${e.message}`); - return; - } - throw new Error('expected the code to throw, but it resolved'); -} diff --git a/tools/device-sync-definitions/intune.mjs b/tools/device-sync-definitions/intune.mjs deleted file mode 100644 index ad09a6274..000000000 --- a/tools/device-sync-definitions/intune.mjs +++ /dev/null @@ -1,124 +0,0 @@ -// Microsoft Intune `device_sync` definition. -// -// This is the source of truth for the `deviceSyncDefinition` that gets authored -// into the Intune dynamic integration's DB row via the internal API (apply.mjs). -// It lives in-repo deliberately: the DB is the only live store for sync DSL and -// the public integrations-catalog export strips it, so without this file the -// definition would be un-versioned and unrecoverable. -// -// Contract (packages/integration-platform/src/dsl/types.ts → SyncDeviceSchema): -// required: name, platform (macos|windows|linux), userEmail, status (active|inactive) -// optional: serialNumber, externalId, hostname, osVersion, hardwareModel -// The import (GenericDeviceSyncService) matches each device to a member by -// lowercased userEmail; a device with no resolvable email is silently skipped. -// -// Auth: Intune is OAuth2, so the interpreter injects `ctx.accessToken` (a Graph -// bearer token). Scope DeviceManagementManagedDevices.Read.All is already -// configured on the connection. - -export const slug = 'intune'; - -// Executed by the DSL interpreter as `new AsyncFunction('ctx','scope', code)`. -// Must populate `scope.devices`. -export const code = ` -const token = ctx.accessToken; -if (!token) { - throw new Error('No Intune access token — reconnect the integration.'); -} - -// Retry transient transport errors and 5xx/429 so a single network blip on one -// page doesn't abort the whole (scheduled) sync. Auth/4xx are returned as-is. -async function fetchRetry(u, opts) { - for (let attempt = 1; ; attempt++) { - let r; - try { - r = await fetch(u, opts); - } catch (e) { - if (attempt >= 3) throw e; - await new Promise((res) => setTimeout(res, 400 * attempt)); - continue; - } - if ((r.status >= 500 || r.status === 429) && attempt < 3) { - await new Promise((res) => setTimeout(res, 400 * attempt)); - continue; - } - return r; - } -} - -const devices = []; -let url = 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$top=100'; -let pages = 0; -// Generous safety backstop against an infinite pagination loop (~100k devices). -// Real tenants stay well under this; hitting it means something is wrong, so we -// fail loudly below rather than report a partial sync as success. -const MAX_PAGES = 1000; - -while (url && pages < MAX_PAGES) { - pages++; - const res = await fetchRetry(url, { - headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' }, - }); - - if (!res.ok) { - if (res.status === 401 || res.status === 403) { - throw new Error( - 'Intune authorization failed (' + res.status + '). Reconnect the integration or grant DeviceManagementManagedDevices.Read.All.', - ); - } - const body = await res.text(); - throw new Error('Intune Graph error ' + res.status + ': ' + body.slice(0, 300)); - } - - const data = await res.json(); - const list = Array.isArray(data.value) ? data.value : []; - - for (const d of list) { - // Platform: the schema is desktop-only. Drop iOS/Android/unknown. - const osRaw = String(d.operatingSystem || '').toLowerCase(); - let platform = null; - if (osRaw.includes('windows')) platform = 'windows'; - else if (osRaw.includes('mac') || osRaw.includes('os x')) platform = 'macos'; - else if (osRaw.includes('linux')) platform = 'linux'; - if (!platform) continue; - - // Owner email: emailAddress, fall back to userPrincipalName (on Entra/M365 - // tenants the UPN equals the user's email). No owner ⇒ import skips it. - const email = String(d.emailAddress || d.userPrincipalName || '').trim().toLowerCase(); - if (!email) continue; - - const serial = String(d.serialNumber || '').trim(); - const externalId = d.id ? String(d.id) : ''; - if (!serial && !externalId) continue; - - const name = String(d.deviceName || d.managedDeviceName || serial || externalId); - - const device = { name: name, platform: platform, userEmail: email, status: 'active' }; - if (serial) device.serialNumber = serial; - if (externalId) device.externalId = externalId; - if (d.osVersion) device.osVersion = String(d.osVersion); - if (d.model) device.hardwareModel = String(d.model); - if (d.deviceName) device.hostname = String(d.deviceName); - devices.push(device); - } - - url = data['@odata.nextLink'] || null; -} - -// If there are still more pages after the cap, abort rather than silently -// importing a partial fleet as a "successful" sync. -if (url) { - throw new Error( - 'Intune device sync exceeded ' + MAX_PAGES + ' pages without finishing — aborting to avoid a partial import.', - ); -} - -ctx.log('Intune device sync mapped ' + devices.length + ' devices'); -scope.devices = devices; -`; - -export const deviceSyncDefinition = { - steps: [{ type: 'code', code }], - devicesPath: 'devices', - isDirectorySource: false, -}; diff --git a/tools/device-sync-definitions/intune.test.mjs b/tools/device-sync-definitions/intune.test.mjs deleted file mode 100644 index b795173d0..000000000 --- a/tools/device-sync-definitions/intune.test.mjs +++ /dev/null @@ -1,93 +0,0 @@ -// Intune device_sync definition tests. Run via test.mjs (or standalone: -// `node tools/device-sync-definitions/intune.test.mjs` + report() won't run). - -import assert from 'node:assert'; -import { code as intuneCode } from './intune.mjs'; -import { test, resp, validate, runCode, makeCtx, expectThrows } from './harness.mjs'; - -console.log('Intune device_sync:'); - -await (async () => { - const page1 = { - value: [ - { id: 'i1', deviceName: 'WIN-1', operatingSystem: 'Windows', osVersion: '10.0', model: 'Surface', emailAddress: 'Win@CO.com', serialNumber: 'W1' }, - { id: 'i2', deviceName: 'MAC-1', operatingSystem: 'macOS', emailAddress: '', userPrincipalName: 'Mac@CO.com', serialNumber: 'M1' }, - { id: 'i3', deviceName: 'IPHONE', operatingSystem: 'iOS', emailAddress: 'phone@co.com' }, - { id: 'i4', deviceName: 'KIOSK', operatingSystem: 'Windows', emailAddress: '', userPrincipalName: '' }, - ], - '@odata.nextLink': 'PAGE2', - }; - const page2 = { - value: [ - { id: 'i5', deviceName: 'LNX-1', operatingSystem: 'Linux', emailAddress: 'lnx@co.com' }, - ], - }; - const fetchImpl = async (url) => { - if (url === 'PAGE2') return resp(200, page2); - if (String(url).includes('managedDevices')) return resp(200, page1); - throw new Error('unexpected url ' + url); - }; - const ctx = makeCtx({ accessToken: 'tok', fetchImpl }); - const raw = await runCode(intuneCode, ctx); - const devices = validate(raw); - - await test('paginates and maps Windows/macOS/Linux, drops iOS + no-email', () => { - assert.equal(devices.length, 3); - const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); - assert.equal(byId.i1.platform, 'windows'); - assert.equal(byId.i2.platform, 'macos'); - assert.equal(byId.i5.platform, 'linux'); - assert(!devices.some((d) => d.externalId === 'i3'), 'iOS must be dropped'); - assert(!devices.some((d) => d.externalId === 'i4'), 'no-email must be dropped'); - }); - - await test('lowercases owner email and falls back to userPrincipalName', () => { - const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); - assert.equal(byId.i1.userEmail, 'win@co.com'); - assert.equal(byId.i2.userEmail, 'mac@co.com'); // UPN fallback - }); - - await test('carries serial, externalId, osVersion, hardwareModel', () => { - const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); - assert.equal(byId.i1.serialNumber, 'W1'); - assert.equal(byId.i1.osVersion, '10.0'); - assert.equal(byId.i1.hardwareModel, 'Surface'); - }); -})(); - -await (async () => { - const ctx = makeCtx({ accessToken: 'tok', fetchImpl: async () => resp(401, 'Unauthorized') }); - await test('throws a clear error on 401', () => - expectThrows(runCode(intuneCode, ctx), /authorization failed/i)); -})(); - -await (async () => { - const ctx = makeCtx({ accessToken: 'tok', fetchImpl: async () => resp(200, { value: [] }) }); - const raw = await runCode(intuneCode, ctx); - await test('produces an empty list when no devices', () => { - assert.deepEqual(validate(raw), []); - }); -})(); - -await (async () => { - let calls = 0; - const fetchImpl = async () => { - calls++; - if (calls === 1) return resp(503, 'busy'); - return resp(200, { - value: [{ id: 'i9', deviceName: 'R', operatingSystem: 'Windows', emailAddress: 'r@co.com', serialNumber: 'R1' }], - }); - }; - const ctx = makeCtx({ accessToken: 'tok', fetchImpl }); - const raw = await runCode(intuneCode, ctx); - await test('retries a transient 503 then succeeds', () => { - assert.equal(validate(raw).length, 1); - assert(calls >= 2, 'should have retried after the 503'); - }); -})(); - -await (async () => { - const ctx = makeCtx({ accessToken: undefined, fetchImpl: async () => resp(200, { value: [] }) }); - await test('throws when there is no access token', () => - expectThrows(runCode(intuneCode, ctx), /no intune access token/i)); -})(); diff --git a/tools/device-sync-definitions/jumpcloud.mjs b/tools/device-sync-definitions/jumpcloud.mjs deleted file mode 100644 index 735f89b2b..000000000 --- a/tools/device-sync-definitions/jumpcloud.mjs +++ /dev/null @@ -1,166 +0,0 @@ -// JumpCloud `device_sync` definition. -// -// Source of truth for the deviceSyncDefinition authored into JumpCloud's DB row -// via the internal API (apply.mjs). See intune.mjs for why this lives in-repo. -// -// JumpCloud's catalog auth type is `custom`, so the interpreter injects NO auth -// header — the code reads the API key from ctx.credentials.api_key and sets the -// `x-api-key` header itself (mirroring the existing JumpCloud employee sync in -// apps/api/src/integration-platform/controllers/sync.controller.ts). -// -// JumpCloud devices ("systems") carry no owner email directly; ownership lives -// in the user→system bindings. We fetch systems + users, then per-user bindings -// to resolve each system's owner email (first bound user wins). A system with no -// bound user resolves to no email and is silently skipped by the import. - -export const slug = 'jumpcloud'; - -export const code = ` -const rawKey = ctx.credentials && ctx.credentials.api_key; -const apiKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; -if (!apiKey) { - throw new Error('JumpCloud API key not found — reconnect the integration.'); -} - -const headers = { 'x-api-key': apiKey, Accept: 'application/json' }; - -// Retry transient transport errors and 5xx/429 so one network blip doesn't -// abort the whole (scheduled) sync. Auth/4xx fall through to the error handling. -async function getJson(url) { - let res; - for (let attempt = 1; ; attempt++) { - try { - res = await fetch(url, { headers: headers }); - } catch (e) { - if (attempt >= 3) throw e; - await new Promise((r) => setTimeout(r, 400 * attempt)); - continue; - } - if ((res.status >= 500 || res.status === 429) && attempt < 3) { - await new Promise((r) => setTimeout(r, 400 * attempt)); - continue; - } - break; - } - if (!res.ok) { - const err = - res.status === 401 - ? new Error('JumpCloud API key is invalid — reconnect the integration.') - : new Error('JumpCloud API error ' + res.status + ': ' + (await res.text()).slice(0, 200)); - err.status = res.status; - throw err; - } - return res.json(); -} - -// 1. All systems (devices), paginated by limit/skip. -const systemsById = {}; -{ - const limit = 100; - let skip = 0; - let more = true; - while (more) { - // Stable sort so limit/skip paging stays consistent if records change mid-sync. - const data = await getJson( - 'https://console.jumpcloud.com/api/systems?limit=' + limit + '&skip=' + skip + '&sort=_id', - ); - const results = (data && data.results) || []; - for (const s of results) systemsById[s._id] = s; - skip += results.length; - more = results.length === limit && skip < (data.totalCount || 0); - if (results.length === 0) more = false; - } -} - -// 2. All users, paginated. -const users = []; -{ - const limit = 100; - let skip = 0; - let more = true; - while (more) { - // Stable sort (matches the employee sync) so paging stays consistent. - const data = await getJson( - 'https://console.jumpcloud.com/api/systemusers?limit=' + limit + '&skip=' + skip + '&sort=email', - ); - const results = (data && data.results) || []; - users.push.apply(users, results); - skip += results.length; - more = results.length === limit && skip < (data.totalCount || 0); - if (results.length === 0) more = false; - } -} - -// 3. Resolve each system's owner email via user→system bindings (first wins). -const ownerEmailBySystemId = {}; -for (const user of users) { - const email = String(user.email || '').trim().toLowerCase(); - if (!email) continue; - let bindings; - try { - // limit=100 (the v2 max) so a user bound to many systems isn't truncated at - // the default page size of 10. - bindings = await getJson( - 'https://console.jumpcloud.com/api/v2/users/' + user._id + '/systems?limit=100', - ); - } catch (e) { - // Only a genuine per-user 404 (user removed mid-sync) is safe to skip. - // Auth/permission/5xx are systemic — rethrow so the sync fails loudly - // instead of silently completing with every device missing an owner. - if (e && e.status === 404) continue; - throw e; - } - if (!Array.isArray(bindings)) continue; - for (const b of bindings) { - if (b && b.id && !ownerEmailBySystemId[b.id]) { - ownerEmailBySystemId[b.id] = email; - } - } -} - -// 4. Emit a SyncDevice per system that has a resolvable owner. -function mapPlatform(os) { - const v = String(os || '').trim().toLowerCase(); - if (!v) return null; // os not reported yet — skip rather than mislabel as linux - if (v.includes('mac') || v.includes('os x') || v.includes('darwin')) return 'macos'; - if (v.includes('windows')) return 'windows'; - // JumpCloud manages only Mac/Windows/Linux, so any other non-empty os is Linux. - return 'linux'; -} - -const devices = []; -for (const id in systemsById) { - const s = systemsById[id]; - const email = ownerEmailBySystemId[id]; - if (!email) continue; - - const platform = mapPlatform(s.os); - if (!platform) continue; - - const serial = String(s.serialNumber || '').trim(); - const externalId = String(s._id || ''); - if (!serial && !externalId) continue; - - const name = String(s.displayName || s.hostname || serial || externalId); - const device = { - name: name, - platform: platform, - userEmail: email, - status: s.active === false ? 'inactive' : 'active', - externalId: externalId, - }; - if (serial) device.serialNumber = serial; - if (s.hostname) device.hostname = String(s.hostname); - if (s.version) device.osVersion = String(s.version); - devices.push(device); -} - -ctx.log('JumpCloud device sync mapped ' + devices.length + ' devices'); -scope.devices = devices; -`; - -export const deviceSyncDefinition = { - steps: [{ type: 'code', code }], - devicesPath: 'devices', - isDirectorySource: false, -}; diff --git a/tools/device-sync-definitions/jumpcloud.test.mjs b/tools/device-sync-definitions/jumpcloud.test.mjs deleted file mode 100644 index 4b7639341..000000000 --- a/tools/device-sync-definitions/jumpcloud.test.mjs +++ /dev/null @@ -1,182 +0,0 @@ -// JumpCloud device_sync definition tests. Run via test.mjs. - -import assert from 'node:assert'; -import { code as jumpcloudCode } from './jumpcloud.mjs'; -import { test, resp, validate, runCode, makeCtx, expectThrows } from './harness.mjs'; - -function jumpcloudFetch({ systems, users, bindings, failSystemsStatus, bindingStatus }) { - return async (url, opts) => { - const u = String(url); - assert(opts && opts.headers && opts.headers['x-api-key'] === 'jc-key', 'must send x-api-key'); - // Honor skip/limit so the production pagination loop is actually exercised. - const params = new URL(u).searchParams; - const limit = Number(params.get('limit')) || 100; - const skip = Number(params.get('skip')) || 0; - const page = (arr) => ({ totalCount: arr.length, results: arr.slice(skip, skip + limit) }); - if (u.includes('/api/systems')) { - if (failSystemsStatus) return resp(failSystemsStatus, 'nope'); - return resp(200, page(systems)); - } - if (u.includes('/api/systemusers')) { - return resp(200, page(users)); - } - const m = u.match(/\/api\/v2\/users\/([^/?]+)\/systems/); - if (m) { - if (bindingStatus) return resp(bindingStatus, 'nope'); - return resp(200, bindings[m[1]] || []); - } - throw new Error('unexpected url ' + u); - }; -} - -console.log('JumpCloud device_sync:'); - -await (async () => { - const systems = [ - { _id: 's1', displayName: 'Alice Mac', os: 'Mac OS X', serialNumber: 'SN1', hostname: 'alice-mac', version: '14.1' }, - { _id: 's2', displayName: 'Bob Win', os: 'Windows', serialNumber: 'SN2' }, - { _id: 's3', displayName: 'Orphan', os: 'Ubuntu', serialNumber: 'SN3' }, - { _id: 's4', displayName: 'Bob Linux', os: 'Ubuntu', serialNumber: 'SN4', active: false }, - { _id: 's5', displayName: 'Enrolling', os: '', serialNumber: 'SN5' }, - ]; - const users = [ - { _id: 'u1', email: 'Alice@CO.com' }, - { _id: 'u2', email: 'bob@co.com' }, - { _id: 'u3', email: '' }, - { _id: 'u4', email: 'dave@co.com' }, - ]; - const bindings = { - u1: [{ id: 's1', type: 'system' }], - u2: [{ id: 's2', type: 'system' }, { id: 's4', type: 'system' }], - u3: [{ id: 's3', type: 'system' }], - u4: [{ id: 's5', type: 'system' }], - }; - const ctx = makeCtx({ - credentials: { api_key: 'jc-key' }, - fetchImpl: jumpcloudFetch({ systems, users, bindings }), - }); - const raw = await runCode(jumpcloudCode, ctx); - const devices = validate(raw); - - await test('joins systems→users→bindings and skips systems with no owner', () => { - assert.equal(devices.length, 3); // s1, s2, s4 (s3 no email, s5 no os) - assert(!devices.some((d) => d.externalId === 's3'), 's3 has no real owner → skipped'); - }); - - await test('skips a system whose os is not yet reported (no mislabel as linux)', () => { - assert(!devices.some((d) => d.externalId === 's5'), 's5 has empty os → skipped'); - }); - - await test('maps Mac/Windows/Linux and lowercases owner email', () => { - const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); - assert.equal(byId.s1.platform, 'macos'); - assert.equal(byId.s1.userEmail, 'alice@co.com'); - assert.equal(byId.s2.platform, 'windows'); - assert.equal(byId.s4.platform, 'linux'); - assert.equal(byId.s4.userEmail, 'bob@co.com'); - }); - - await test('reflects system.active in status and carries serial/hostname/osVersion', () => { - const byId = Object.fromEntries(devices.map((d) => [d.externalId, d])); - assert.equal(byId.s1.status, 'active'); - assert.equal(byId.s4.status, 'inactive'); - assert.equal(byId.s1.serialNumber, 'SN1'); - assert.equal(byId.s1.hostname, 'alice-mac'); - assert.equal(byId.s1.osVersion, '14.1'); - }); -})(); - -await (async () => { - const ctx = makeCtx({ - credentials: { api_key: 'jc-key' }, - fetchImpl: jumpcloudFetch({ systems: [], users: [], bindings: {}, failSystemsStatus: 401 }), - }); - await test('throws a clear error on 401', () => - expectThrows(runCode(jumpcloudCode, ctx), /api key is invalid/i)); -})(); - -await (async () => { - // 150 systems each owned by its own user forces multi-page skip/limit fetches. - const systems = []; - const users = []; - const bindings = {}; - for (let i = 0; i < 150; i++) { - const sid = 's' + i; - const uid = 'u' + i; - systems.push({ _id: sid, displayName: 'Dev ' + i, os: 'Windows', serialNumber: 'SN' + i }); - users.push({ _id: uid, email: 'user' + i + '@co.com' }); - bindings[uid] = [{ id: sid, type: 'system' }]; - } - const ctx = makeCtx({ - credentials: { api_key: 'jc-key' }, - fetchImpl: jumpcloudFetch({ systems, users, bindings }), - }); - const devices = validate(await runCode(jumpcloudCode, ctx)); - await test('paginates systems + users across pages (skip/limit honored)', () => { - assert.equal(devices.length, 150); - }); -})(); - -await (async () => { - const systems = [{ _id: 's1', displayName: 'A', os: 'Windows', serialNumber: 'SN1' }]; - const users = [{ _id: 'u1', email: 'a@co.com' }]; - const ctx = makeCtx({ - credentials: { api_key: 'jc-key' }, - fetchImpl: jumpcloudFetch({ systems, users, bindings: {}, bindingStatus: 401 }), - }); - await test('rethrows a 401 on the bindings call (no silent empty sync)', () => - expectThrows(runCode(jumpcloudCode, ctx), /api key is invalid/i)); -})(); - -await (async () => { - const systems = [{ _id: 's1', displayName: 'A', os: 'Windows', serialNumber: 'SN1' }]; - const users = [{ _id: 'u1', email: 'a@co.com' }]; - const ctx = makeCtx({ - credentials: { api_key: 'jc-key' }, - fetchImpl: jumpcloudFetch({ systems, users, bindings: {}, bindingStatus: 404 }), - }); - const devices = validate(await runCode(jumpcloudCode, ctx)); - await test('skips a per-user 404 on bindings without failing the sync', () => { - assert.equal(devices.length, 0); // s1 gets no owner → skipped, but no throw - }); -})(); - -await (async () => { - const ctx = makeCtx({ credentials: {}, fetchImpl: async () => resp(200, {}) }); - await test('throws when API key is missing', () => - expectThrows(runCode(jumpcloudCode, ctx), /api key not found/i)); -})(); - -// Retry/transient-failure coverage for getJson: a network error, a 5xx, and a -// 429 on the first systems fetch should each be retried and then succeed. -for (const transient of [ - { - label: 'a network error', - fail: () => { - throw new Error('ECONNRESET'); - }, - }, - { label: 'a 5xx', fail: () => resp(503, 'busy') }, - { label: 'a 429', fail: () => resp(429, 'slow down') }, -]) { - await (async () => { - let calls = 0; - const systems = [{ _id: 's1', displayName: 'A', os: 'Windows', serialNumber: 'SN1' }]; - const users = [{ _id: 'u1', email: 'a@co.com' }]; - const bindings = { u1: [{ id: 's1', type: 'system' }] }; - const base = jumpcloudFetch({ systems, users, bindings }); - const fetchImpl = async (url, opts) => { - if (String(url).includes('/api/systems')) { - calls++; - if (calls === 1) return transient.fail(); - } - return base(url, opts); - }; - const ctx = makeCtx({ credentials: { api_key: 'jc-key' }, fetchImpl }); - const devices = validate(await runCode(jumpcloudCode, ctx)); - await test('retries ' + transient.label + ' then succeeds', () => { - assert.equal(devices.length, 1); - assert(calls >= 2, 'should have retried the transient failure'); - }); - })(); -} diff --git a/tools/device-sync-definitions/test.mjs b/tools/device-sync-definitions/test.mjs deleted file mode 100644 index 67344fcaa..000000000 --- a/tools/device-sync-definitions/test.mjs +++ /dev/null @@ -1,15 +0,0 @@ -// Runs the Intune + JumpCloud device_sync definition tests offline against -// canned API responses. Run: node tools/device-sync-definitions/test.mjs -// -// Each provider's tests live in its own file (kept small per repo conventions); -// the shared mock harness is in harness.mjs. -// -// Imported SEQUENTIALLY via dynamic import: the test modules use top-level await -// and the mock stubs the global `fetch`, so they must run one-at-a-time — -// static imports would interleave their awaits and clobber each other's fetch. - -await import('./intune.test.mjs'); -await import('./jumpcloud.test.mjs'); - -const { report } = await import('./harness.mjs'); -report(); From 9056d1b7c47ea8fffc56fc83fee2db77eea439ff Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 30 Jun 2026 16:33:41 -0400 Subject: [PATCH 16/18] fix(devices): address cubic review on the device-import display PR (#3308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-devices + fleet-hosts routes: enforce RBAC with requireApiPermission (member:read, matching the People page) instead of a session-only check, so the device/integration data can't be read by an active-org session that lacks people access (P1) - CSV export: use the shared isComplianceTracked() so Fleet devices aren't wrongly marked "not_tracked"/"n/a" — keeps CSV consistent with the UI (P2) - DRY: move shared device presentation helpers (PLATFORM_LABELS, CHECK_FIELDS, formatTimeAgo, isDeviceOnline, stale + not-tracked copy) into device-source, and a shared NotTrackedBadge — used by both the list and details views (P3) - Source filter: key options by a stable source id (sourceKey) instead of the display label, so two providers sharing a name don't collapse into one (P3) Tests: devices suite + agent-devices route (81) green; typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015iDU78gxNH9Wp9sex1BDLS --- .../DeviceAgentDevicesList.test.tsx | 29 ++++++- .../components/DeviceAgentDevicesList.tsx | 19 +++-- .../devices/components/DeviceDetails.tsx | 62 ++------------ .../devices/components/DeviceListCells.tsx | 82 +++++++------------ .../people/devices/lib/device-source.ts | 65 ++++++++++++++- .../[orgId]/people/devices/lib/devices-csv.ts | 8 +- .../api/people/agent-devices/route.test.ts | 57 +++++++------ .../src/app/api/people/agent-devices/route.ts | 18 ++-- .../src/app/api/people/fleet-hosts/route.ts | 16 ++-- 9 files changed, 191 insertions(+), 165 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx index abe8e4077..704fe6ef3 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { DeviceWithChecks } from '../types'; @@ -261,9 +261,34 @@ describe('DeviceAgentDevicesList — integration-imported devices', () => { expect(screen.getByText('Imported Mac')).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('Filter by source'), { - target: { value: 'Kandji' }, + target: { value: 'integration:kandji' }, }); expect(screen.queryByText('Agent Mac')).not.toBeInTheDocument(); expect(screen.getByText('Imported Mac')).toBeInTheDocument(); }); + + it('keeps distinct providers separate in the filter even with the same display name', () => { + render( + , + ); + const select = screen.getByLabelText('Filter by source'); + // "All sources" + two distinct providers (not merged into one "MDM"). + expect(within(select).getAllByRole('option')).toHaveLength(3); + fireEvent.change(select, { target: { value: 'integration:intune' } }); + expect(screen.queryByText('Device X')).not.toBeInTheDocument(); + expect(screen.getByText('Device Y')).toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx index 82a16b88c..4b47758da 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx @@ -30,7 +30,7 @@ import { downloadDevicesCsv, } from '../lib/devices-csv'; import { DeviceTableRow } from './DeviceListCells'; -import { sourceLabel } from '../lib/device-source'; +import { sourceKey, sourceLabel } from '../lib/device-source'; import { DeviceDetails } from './DeviceDetails'; import { RemoveDeviceAlert } from '../../all/components/RemoveDeviceAlert'; @@ -55,17 +55,20 @@ export const DeviceAgentDevicesList = ({ const [isRemoveDeviceAlertOpen, setIsRemoveDeviceAlertOpen] = useState(false); const [isRemovingDevice, setIsRemovingDevice] = useState(false); - // Distinct source labels present, so the filter only offers sources that exist - // (e.g. "Comp Agent", "Kandji"). + // Distinct sources present, keyed by a stable id (so two providers that share + // a display name don't collapse into one option) with a label for display. const sourceOptions = useMemo(() => { - const labels = new Set(devices.map((d) => sourceLabel(d))); - return Array.from(labels).sort(); + const byKey = new Map(); + for (const d of devices) byKey.set(sourceKey(d), sourceLabel(d)); + return Array.from(byKey, ([key, label]) => ({ key, label })).sort((a, b) => + a.label.localeCompare(b.label), + ); }, [devices]); const filteredDevices = useMemo(() => { const query = searchQuery.toLowerCase(); return devices.filter((device) => { - if (sourceFilter !== 'all' && sourceLabel(device) !== sourceFilter) { + if (sourceFilter !== 'all' && sourceKey(device) !== sourceFilter) { return false; } if (!query) return true; @@ -154,8 +157,8 @@ export const DeviceAgentDevicesList = ({ aria-label="Filter by source" > - {sourceOptions.map((label) => ( - ))} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx index eca2ac60c..536229aaa 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx @@ -18,63 +18,19 @@ import { import { ArrowLeft, Information } from '@trycompai/design-system/icons'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; import type { DeviceWithChecks } from '../types'; +import { + CHECK_FIELDS, + PLATFORM_LABELS, + isDeviceOnline, + staleLabel, + staleTooltipCopy, +} from '../lib/device-source'; +import { NotTrackedBadge } from './DeviceListCells'; import { RevokeAgentAccessDialog } from './RevokeAgentAccessDialog'; -const CHECK_FIELDS = [ - { key: 'diskEncryptionEnabled' as const, dbKey: 'disk_encryption', label: 'Disk Encryption' }, - { key: 'antivirusEnabled' as const, dbKey: 'antivirus', label: 'Antivirus' }, - { key: 'passwordPolicySet' as const, dbKey: 'password_policy', label: 'Password Policy' }, - { key: 'screenLockEnabled' as const, dbKey: 'screen_lock', label: 'Screen Lock' }, -]; - -const PLATFORM_LABELS: Record = { - macos: 'macOS', - windows: 'Windows', - linux: 'Linux', -}; - -/** Device is considered online if it checked in within the last 2 hours */ -function isDeviceOnline(lastCheckIn: string | null): boolean { - if (!lastCheckIn) return false; - const diffMs = Date.now() - new Date(lastCheckIn).getTime(); - return diffMs < 2 * 60 * 60 * 1000; -} - -function staleLabel(daysSinceLastCheckIn: number | null): string { - return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; -} - -function staleTooltipCopy(daysSinceLastCheckIn: number | null): string { - return daysSinceLastCheckIn === null - ? "This device was registered but hasn't sent a compliance check yet. If it's not new, the agent may not be running or the device may be offline." - : "This device hasn't reported to CompAI in over 7 days, so we can't verify its current compliance. It may be offline, the agent may need to be updated, or the device may no longer be in use. Check with the employee."; -} - function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { if (device.source === 'integration') { - const provider = device.integrationProvider?.name ?? 'an integration'; - return ( -
- Not tracked - - - - - - - {`This device was imported from ${provider}. CompAI doesn't collect compliance checks for imported devices — install the CompAI agent to track its security posture.`} - - - -
- ); + return ; } if (device.complianceStatus === 'stale') { return ( diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx index 2654cbca9..74db9d138 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx @@ -23,50 +23,17 @@ import { } from '@trycompai/ui/tooltip'; import Link from 'next/link'; import type { DeviceWithChecks } from '../types'; -import { isComplianceTracked, sourceLabel } from '../lib/device-source'; - -export const CHECK_FIELDS = [ - { key: 'diskEncryptionEnabled' as const, label: 'Disk Encryption' }, - { key: 'antivirusEnabled' as const, label: 'Antivirus' }, - { key: 'passwordPolicySet' as const, label: 'Password Policy' }, - { key: 'screenLockEnabled' as const, label: 'Screen Lock' }, -]; - -export const PLATFORM_LABELS: Record = { - macos: 'macOS', - windows: 'Windows', - linux: 'Linux', -}; - -export function formatTimeAgo(dateString: string | null): string { - if (!dateString) return 'Never'; - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - - if (diffHours < 1) return 'Just now'; - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; -} - -/** Device is considered online if it checked in within the last 2 hours. */ -export function isDeviceOnline(lastCheckIn: string | null): boolean { - if (!lastCheckIn) return false; - const diffMs = Date.now() - new Date(lastCheckIn).getTime(); - return diffMs < 2 * 60 * 60 * 1000; -} - -function staleLabel(daysSinceLastCheckIn: number | null): string { - return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; -} - -function staleTooltipCopy(daysSinceLastCheckIn: number | null): string { - return daysSinceLastCheckIn === null - ? "This device was registered but hasn't sent a compliance check yet. If it's not new, the agent may not be running or the device may be offline." - : "This device hasn't reported to CompAI in over 7 days, so we can't verify its current compliance. It may be offline, the agent may need to be updated, or the device may no longer be in use. Check with the employee."; -} +import { + CHECK_FIELDS, + PLATFORM_LABELS, + formatTimeAgo, + isComplianceTracked, + isDeviceOnline, + notTrackedTooltipCopy, + sourceLabel, + staleLabel, + staleTooltipCopy, +} from '../lib/device-source'; function InfoTooltip({ label, copy }: { label: string; copy: string }) { return ( @@ -124,21 +91,28 @@ export function UserNameCell({ ); } +/** + * Compliance badge for an integration-imported device. Shared with the details + * panel so the "Not tracked" copy and provider fallback stay consistent. + */ +export function NotTrackedBadge({ device }: { device: DeviceWithChecks }) { + return ( +
+ Not tracked + +
+ ); +} + export function CompliantBadge({ device }: { device: DeviceWithChecks }) { // Integration-imported devices are inventory records, not compliance records — // CompAI never ran security checks on them, so showing "No" (red) would be a // false negative. Present them as untracked instead. if (!isComplianceTracked(device)) { - const provider = device.integrationProvider?.name ?? 'an integration'; - return ( -
- Not tracked - -
- ); + return ; } if (device.complianceStatus === 'stale') { diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts index 0ea9f8adb..5658cae31 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts @@ -1,8 +1,8 @@ import type { DeviceWithChecks } from '../types'; /** - * Human label for where a device came from. Shared by the devices table and the - * CSV export so the two can't drift on source labels. + * Human label for where a device came from. Shared by the devices table, the + * details panel, and the CSV export so they can't drift on source labels. */ export function sourceLabel(device: DeviceWithChecks): string { if (device.source === 'integration') { @@ -12,6 +12,18 @@ export function sourceLabel(device: DeviceWithChecks): string { return 'Comp Agent'; } +/** + * Stable identifier for a device's source — used to key the source filter so two + * distinct providers that happen to share a display name don't collapse into one + * option. (sourceLabel is for display only.) + */ +export function sourceKey(device: DeviceWithChecks): string { + if (device.source === 'integration') { + return `integration:${device.integrationProvider?.slug ?? 'unknown'}`; + } + return device.source; // 'device_agent' | 'fleet' +} + /** * True for devices whose compliance posture CompAI actually collects. Only * integration-imported devices are inventory-only ("Not tracked"); agent and @@ -20,3 +32,52 @@ export function sourceLabel(device: DeviceWithChecks): string { export function isComplianceTracked(device: DeviceWithChecks): boolean { return device.source !== 'integration'; } + +// --------------------------------------------------------------------------- +// Shared device presentation helpers (used by both the list and details views +// so copy/labels/thresholds can't drift between them). +// --------------------------------------------------------------------------- + +export const PLATFORM_LABELS: Record = { + macos: 'macOS', + windows: 'Windows', + linux: 'Linux', +}; + +export const CHECK_FIELDS = [ + { key: 'diskEncryptionEnabled' as const, dbKey: 'disk_encryption', label: 'Disk Encryption' }, + { key: 'antivirusEnabled' as const, dbKey: 'antivirus', label: 'Antivirus' }, + { key: 'passwordPolicySet' as const, dbKey: 'password_policy', label: 'Password Policy' }, + { key: 'screenLockEnabled' as const, dbKey: 'screen_lock', label: 'Screen Lock' }, +]; + +export function formatTimeAgo(dateString: string | null): string { + if (!dateString) return 'Never'; + const diffMs = Date.now() - new Date(dateString).getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + if (diffHours < 1) return 'Just now'; + if (diffHours < 24) return `${diffHours}h ago`; + return `${Math.floor(diffHours / 24)}d ago`; +} + +/** Device is considered online if it checked in within the last 2 hours. */ +export function isDeviceOnline(lastCheckIn: string | null): boolean { + if (!lastCheckIn) return false; + return Date.now() - new Date(lastCheckIn).getTime() < 2 * 60 * 60 * 1000; +} + +export function staleLabel(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; +} + +export function staleTooltipCopy(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null + ? "This device was registered but hasn't sent a compliance check yet. If it's not new, the agent may not be running or the device may be offline." + : "This device hasn't reported to CompAI in over 7 days, so we can't verify its current compliance. It may be offline, the agent may need to be updated, or the device may no longer be in use. Check with the employee."; +} + +/** Tooltip copy for the "Not tracked" badge on integration-imported devices. */ +export function notTrackedTooltipCopy(device: DeviceWithChecks): string { + const provider = device.integrationProvider?.name ?? 'an integration'; + return `This device was imported from ${provider}. CompAI doesn't collect compliance checks for imported devices — install the CompAI agent to track its security posture.`; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts index 36b4cf23c..638ab9aaf 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts @@ -1,5 +1,5 @@ import type { DeviceWithChecks } from '../types'; -import { sourceLabel } from './device-source'; +import { isComplianceTracked, sourceLabel } from './device-source'; export const DEVICES_CSV_HEADER = [ 'Device Name', @@ -38,9 +38,9 @@ function yesNo(value: boolean): 'yes' | 'no' { export function buildDevicesCsv(devices: DeviceWithChecks[]): string { const rows = devices.map((d) => { - // Only agent devices carry real compliance data. For imported/fleet devices, - // export "not_tracked"/"n/a" rather than a misleading non_compliant + "no". - const tracked = d.source === 'device_agent'; + // Integration-imported devices are inventory-only; agent + Fleet carry real + // compliance. Use the shared helper so the CSV matches the rest of the UI. + const tracked = isComplianceTracked(d); const status = tracked ? d.complianceStatus : 'not_tracked'; const check = (value: boolean) => (tracked ? yesNo(value) : 'n/a'); return [ diff --git a/apps/app/src/app/api/people/agent-devices/route.test.ts b/apps/app/src/app/api/people/agent-devices/route.test.ts index d61d763ae..cc1b65d73 100644 --- a/apps/app/src/app/api/people/agent-devices/route.test.ts +++ b/apps/app/src/app/api/people/agent-devices/route.test.ts @@ -1,15 +1,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextResponse } from 'next/server'; -vi.mock('@/utils/auth', () => ({ - auth: { - api: { - getSession: vi.fn(), - }, - }, -})); - -vi.mock('next/headers', () => ({ - headers: vi.fn(async () => new Headers()), +vi.mock('@/lib/permissions.server', () => ({ + requireApiPermission: vi.fn(), })); vi.mock('@db/server', () => ({ @@ -17,14 +10,17 @@ vi.mock('@db/server', () => ({ device: { findMany: vi.fn(), }, + integrationConnection: { + findMany: vi.fn(async () => []), + }, }, })); -import { auth } from '@/utils/auth'; +import { requireApiPermission } from '@/lib/permissions.server'; import { db } from '@db/server'; import { GET } from './route'; -const mockedGetSession = vi.mocked(auth.api.getSession); +const mockedRequire = vi.mocked(requireApiPermission); const mockedFindMany = vi.mocked( (db as unknown as { device: { findMany: ReturnType } }).device.findMany, ); @@ -32,6 +28,18 @@ const mockedFindMany = vi.mocked( // Freeze "now" so day math is deterministic. const FIXED_NOW = new Date('2026-04-17T12:00:00.000Z'); +function req() { + return new Request('http://test/api/people/agent-devices'); +} + +function grant() { + mockedRequire.mockResolvedValue({ + organizationId: 'org_1', + userId: 'u_1', + permissions: {}, + } as unknown as Awaited>); +} + beforeAll(() => { vi.useFakeTimers(); vi.setSystemTime(FIXED_NOW); @@ -43,9 +51,7 @@ afterAll(() => { beforeEach(() => { vi.clearAllMocks(); - mockedGetSession.mockResolvedValue({ - session: { activeOrganizationId: 'org_1' }, - } as unknown as Awaited>); + grant(); }); function deviceRow(overrides: Partial> = {}) { @@ -73,15 +79,18 @@ function deviceRow(overrides: Partial> = {}) { } describe('GET /api/people/agent-devices', () => { - it('returns 401 when no organization is active', async () => { - mockedGetSession.mockResolvedValue({ session: {} } as never); - const res = await GET(); - expect(res.status).toBe(401); + it('forwards the 401/403 response when the RBAC guard denies access', async () => { + mockedRequire.mockResolvedValue( + NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + ); + const res = await GET(req()); + expect(res.status).toBe(403); + expect(mockedFindMany).not.toHaveBeenCalled(); }); it('marks a fresh + isCompliant device as compliant', async () => { mockedFindMany.mockResolvedValue([deviceRow({ lastCheckIn: new Date(FIXED_NOW) })]); - const res = await GET(); + const res = await GET(req()); const body = await res.json(); expect(body.data[0].complianceStatus).toBe('compliant'); expect(body.data[0].daysSinceLastCheckIn).toBe(0); @@ -95,7 +104,7 @@ describe('GET /api/people/agent-devices', () => { lastCheckIn: new Date(FIXED_NOW), }), ]); - const res = await GET(); + const res = await GET(req()); const body = await res.json(); expect(body.data[0].complianceStatus).toBe('non_compliant'); }); @@ -103,7 +112,7 @@ describe('GET /api/people/agent-devices', () => { it('marks a device with lastCheckIn >= 7 days ago as stale', async () => { const eightDaysAgo = new Date(FIXED_NOW.getTime() - 8 * 24 * 60 * 60 * 1000); mockedFindMany.mockResolvedValue([deviceRow({ lastCheckIn: eightDaysAgo })]); - const res = await GET(); + const res = await GET(req()); const body = await res.json(); expect(body.data[0].complianceStatus).toBe('stale'); expect(body.data[0].daysSinceLastCheckIn).toBe(8); @@ -111,7 +120,7 @@ describe('GET /api/people/agent-devices', () => { it('marks a device with null lastCheckIn as stale', async () => { mockedFindMany.mockResolvedValue([deviceRow({ lastCheckIn: null })]); - const res = await GET(); + const res = await GET(req()); const body = await res.json(); expect(body.data[0].complianceStatus).toBe('stale'); expect(body.data[0].daysSinceLastCheckIn).toBeNull(); @@ -127,7 +136,7 @@ describe('GET /api/people/agent-devices', () => { deviceRow({ id: 'dev_expired', agentSession: { expiresAt: past } }), ]); - const res = await GET(); + const res = await GET(req()); const body = await res.json(); const byId = Object.fromEntries( body.data.map((d: { id: string }) => [d.id, d]), diff --git a/apps/app/src/app/api/people/agent-devices/route.ts b/apps/app/src/app/api/people/agent-devices/route.ts index 30c14386f..c44fa85ae 100644 --- a/apps/app/src/app/api/people/agent-devices/route.ts +++ b/apps/app/src/app/api/people/agent-devices/route.ts @@ -1,7 +1,6 @@ -import { auth } from '@/utils/auth'; import { db } from '@db/server'; -import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; +import { requireApiPermission } from '@/lib/permissions.server'; import { daysSinceCheckIn, getDeviceComplianceStatus, @@ -15,13 +14,14 @@ function mapSource(source: string): DeviceWithChecks['source'] { return 'device_agent'; } -export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }); - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } +export async function GET(req: Request) { + // Enforce the same RBAC as the People area (route permission 'people' = + // member:read). The session-only check was insufficient — this route returns + // org device + integration-provider data and can be called directly by any + // active-org session, so gate it explicitly. + const ctx = await requireApiPermission(req, 'member', 'read'); + if (ctx instanceof NextResponse) return ctx; + const { organizationId } = ctx; const devices = await db.device.findMany({ where: { diff --git a/apps/app/src/app/api/people/fleet-hosts/route.ts b/apps/app/src/app/api/people/fleet-hosts/route.ts index 4d65c5296..4046f585b 100644 --- a/apps/app/src/app/api/people/fleet-hosts/route.ts +++ b/apps/app/src/app/api/people/fleet-hosts/route.ts @@ -1,19 +1,17 @@ -import { auth } from '@/utils/auth'; import { getFleetInstance } from '@/lib/fleet'; import { db } from '@db/server'; -import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; +import { requireApiPermission } from '@/lib/permissions.server'; import type { Host } from '@/app/(app)/[orgId]/people/devices/types'; const MDM_POLICY_ID = -9999; -export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }); - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } +export async function GET(req: Request) { + // Same RBAC as the People area (member:read) — this returns org device data + // and must not be reachable by an active-org session lacking people access. + const ctx = await requireApiPermission(req, 'member', 'read'); + if (ctx instanceof NextResponse) return ctx; + const { organizationId } = ctx; const fleet = await getFleetInstance(); From ae88ec66e3ef3cbdf5c8f12508136cc531fbef32 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 30 Jun 2026 16:41:47 -0400 Subject: [PATCH 17/18] feat(people): add "Don't auto-sync" option to employee sync source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CS-576 (re-open): customers could select an employee sync source in the People tab but had no way to turn it back off. The only "off" levers were disconnecting the integration (which also kills its compliance checks and is labelled "remove all data") or an email-filter workaround — neither is a real disable. The org-level `employeeSyncProvider` already supports null end-to-end (`POST /employee-sync-provider {provider:null}` + `setSyncProvider(null)`), and the daily 7 AM cron skips orgs whose provider is null — it was just never wired to a UI control. This adds a "Don't auto-sync" item to the sync-source dropdown that clears the provider (stopping the scheduled sync) without disconnecting the integration or touching already-imported people. Applies to every sync provider, not just Entra. Selecting it routes to disable (never triggers a sync), and the hook now confirms with a toast. Gated by `canManageMembers`; backend still enforces `integration:update`. Tests: cover setSyncProvider(null) disabling + provider selection. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M6zcF9bvPP1UwZgzK21cw1 --- .../all/components/TeamMembersClient.tsx | 33 +++++++- .../people/all/hooks/useEmployeeSync.test.ts | 75 +++++++++++++++++++ .../people/all/hooks/useEmployeeSync.ts | 2 + 3 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.test.ts diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index c481f278b..23990a44c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -53,6 +53,11 @@ import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMemb import type { EmployeeSyncConnectionsData } from '../data/queries'; import { useEmployeeSync } from '../hooks/useEmployeeSync'; +// Sentinel value for the "Don't auto-sync" item in the sync-source dropdown. +// Radix Select items can't have an empty value, so disabling is modeled as a +// distinct option rather than the absence of a selection. +const NO_SYNC_VALUE = '__no_sync__'; + interface TeamMembersClientProps { data: TeamMembersData; organizationId: string; @@ -119,6 +124,7 @@ export function TeamMembersClient({ selectedProvider, isSyncing, syncEmployees, + setSyncProvider, hasAnyConnection, getProviderName, getProviderLogo, @@ -137,6 +143,14 @@ export function TeamMembersClient({ } }; + // Turn off the daily auto-sync without disconnecting the integration (which + // would also stop its compliance checks). Clears the org's sync provider so + // the scheduled job skips it; already-imported people are left untouched. + const handleDisableSync = async () => { + if (!selectedProvider) return; + await setSyncProvider(null); + }; + const allItems = buildDisplayItems(data); const hasOffboardFilter = !!(offboardFrom || offboardTo); const effectiveStatusFilter = hasOffboardFilter && !statusFilter ? 'all' : statusFilter; @@ -358,11 +372,13 @@ export function TeamMembersClient({
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.test.ts b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.test.ts new file mode 100644 index 000000000..06ff41770 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.test.ts @@ -0,0 +1,75 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { EmployeeSyncConnectionsData } from '../data/queries'; + +vi.mock('@/lib/api-client', () => ({ + apiClient: { post: vi.fn().mockResolvedValue({ data: { success: true } }) }, +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { apiClient } from '@/lib/api-client'; +import { toast } from 'sonner'; +import { useEmployeeSync } from './useEmployeeSync'; + +const initialData: EmployeeSyncConnectionsData = { + googleWorkspaceConnectionId: null, + ripplingConnectionId: null, + jumpcloudConnectionId: null, + selectedProvider: 'entra-id', + lastSyncAt: null, + nextSyncAt: null, + availableProviders: [ + { + slug: 'entra-id', + name: 'Microsoft Entra ID', + logoUrl: '', + connected: true, + connectionId: 'conn_1', + lastSyncAt: null, + nextSyncAt: null, + }, + ], +}; + +const ENDPOINT = + '/v1/integrations/sync/employee-sync-provider?organizationId=org_1'; + +describe('useEmployeeSync.setSyncProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('turns off auto-sync by POSTing provider: null and confirms to the user', async () => { + const { result } = renderHook(() => + useEmployeeSync({ organizationId: 'org_1', initialData }), + ); + + await act(async () => { + await result.current.setSyncProvider(null); + }); + + expect(apiClient.post).toHaveBeenCalledWith(ENDPOINT, { provider: null }); + expect(toast.success).toHaveBeenCalledWith('Employee auto-sync turned off'); + }); + + it('selects a provider by POSTing its slug', async () => { + const { result } = renderHook(() => + useEmployeeSync({ organizationId: 'org_1', initialData }), + ); + + await act(async () => { + await result.current.setSyncProvider('entra-id'); + }); + + expect(apiClient.post).toHaveBeenCalledWith(ENDPOINT, { + provider: 'entra-id', + }); + expect(toast.success).toHaveBeenCalledWith( + 'Microsoft Entra ID set as your employee sync provider', + ); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts index d7fa5e592..e835e9141 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/hooks/useEmployeeSync.ts @@ -97,6 +97,8 @@ export const useEmployeeSync = ({ ? PROVIDER_CONFIG[provider as BuiltInSyncProvider].name : (availableProviders.find((p) => p.slug === provider)?.name ?? provider); toast.success(`${name} set as your employee sync provider`); + } else { + toast.success('Employee auto-sync turned off'); } } catch (error) { toast.error('Failed to set sync provider'); From 541a72fc107000eaeaa536296a504f8b1fccc71f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 30 Jun 2026 16:48:08 -0400 Subject: [PATCH 18/18] fix(people): lock sync-source dropdown while disabling auto-sync Addresses cubic P2: the "Don't auto-sync" path called setSyncProvider(null) without any busy flag, so the dropdown stayed interactive during the request and a follow-up provider selection could race it to an inconsistent state. Add an isDisablingSync flag (mirroring isSyncing): set it around the disable request, include it in the Select's disabled guard, and bail out of the handler if a disable is already in flight. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M6zcF9bvPP1UwZgzK21cw1 --- .../people/all/components/TeamMembersClient.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 23990a44c..bb4d9772d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -133,6 +133,7 @@ export function TeamMembersClient({ const lastSyncAt = employeeSyncData.lastSyncAt; const nextSyncAt = employeeSyncData.nextSyncAt; + const [isDisablingSync, setIsDisablingSync] = useState(false); const handleEmployeeSync = async ( provider: string, @@ -146,9 +147,16 @@ export function TeamMembersClient({ // Turn off the daily auto-sync without disconnecting the integration (which // would also stop its compliance checks). Clears the org's sync provider so // the scheduled job skips it; already-imported people are left untouched. + // Sets a busy flag (mirroring isSyncing) so the dropdown is locked while the + // request is in flight, preventing an overlapping provider change from racing. const handleDisableSync = async () => { - if (!selectedProvider) return; - await setSyncProvider(null); + if (!selectedProvider || isDisablingSync) return; + setIsDisablingSync(true); + try { + await setSyncProvider(null); + } finally { + setIsDisablingSync(false); + } }; const allItems = buildDisplayItems(data); @@ -380,7 +388,7 @@ export function TeamMembersClient({ } handleEmployeeSync(provider); }} - disabled={isSyncing || !canManageMembers} + disabled={isSyncing || isDisablingSync || !canManageMembers} > {isSyncing ? (