diff --git a/.github/workflows/database-migrations-main.yml b/.github/workflows/database-migrations-main.yml index 3fd87ba7d3..87a710c345 100644 --- a/.github/workflows/database-migrations-main.yml +++ b/.github/workflows/database-migrations-main.yml @@ -4,6 +4,10 @@ on: branches: - main workflow_dispatch: # Allows manual triggering + +permissions: + contents: read # only needs to check out the repo to run migrations + jobs: migrate: name: Run Database Migrations diff --git a/.github/workflows/database-migrations-release.yml b/.github/workflows/database-migrations-release.yml index b64c4798d0..6191bf3086 100644 --- a/.github/workflows/database-migrations-release.yml +++ b/.github/workflows/database-migrations-release.yml @@ -4,6 +4,10 @@ on: branches: - release workflow_dispatch: # Allows manual triggering + +permissions: + contents: read # only needs to check out the repo to run migrations + jobs: migrate: name: Run Database Migrations diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml new file mode 100644 index 0000000000..107fd13e24 --- /dev/null +++ b/.github/workflows/sbom.yml @@ -0,0 +1,61 @@ +name: SBOM + +# Publishes an accurate SBOM to GitHub's dependency graph. +# +# GitHub does NOT natively parse `bun.lock`, so out of the box its dependency +# graph, exported SBOM, and Dependabot are blind to the bun dependency tree +# (they only see declared package.json ranges + the npm package-lock.json in +# apps/mcp-server). Syft DOES parse bun.lock with fully resolved versions +# (overrides applied), so this workflow scans bun.lock with Syft and submits +# the resolved tree via GitHub's Dependency Submission API. (apps/mcp-server's +# package-lock.json is already covered by GitHub's native npm parsing, so we +# only need to submit the bun tree.) After this runs on `main`, GitHub's own +# "Export SBOM" and Dependabot reflect the real bun tree. +# +# NOTE: we scan bun.lock via the `file:` input, not `path: .`. Syft's default +# DIRECTORY catalogers do not include the bun cataloger, so a directory scan +# silently skips bun.lock (it only picks up package-lock.json + actions). A +# file scan forces the bun cataloger and yields the full resolved tree. + +on: + push: + branches: [main] + paths: + - 'bun.lock' + - '.github/workflows/sbom.yml' + schedule: + - cron: '0 6 * * 1' # weekly, Monday 06:00 UTC — refresh against new advisories + workflow_dispatch: + +permissions: + contents: write # required by the Dependency Submission API + +concurrency: + group: sbom-${{ github.ref }} + cancel-in-progress: true + +jobs: + sbom: + name: Generate & submit SBOM + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Scans bun.lock directly (see NOTE above). `dependency-snapshot: true` + # submits the resolved tree to the dependency graph; the SBOM is also + # uploaded as a workflow artifact for anyone who wants the file directly. + - name: Generate SBOM & submit to dependency graph + # Pinned to a full commit SHA (not the mutable @v0 tag): this action + # runs with contents:write, so a retag/compromise must not be able to + # swap in altered code. Bump the SHA + comment together to upgrade. + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + file: bun.lock + format: spdx-json + artifact-name: sbom.spdx.json + dependency-snapshot: true + # sbom-action v0.24.0 bundles Syft 1.42.3, which CANNOT parse bun.lock + # (it yields 1 package — an empty snapshot). Pin a Syft that can: + # verified 1.46.0 → 2496 resolved deps. Bump alongside the action SHA. + syft-version: v1.46.0 diff --git a/.github/workflows/trigger-tasks-deploy-main.yml b/.github/workflows/trigger-tasks-deploy-main.yml index b86b1a4166..8a171da69b 100644 --- a/.github/workflows/trigger-tasks-deploy-main.yml +++ b/.github/workflows/trigger-tasks-deploy-main.yml @@ -3,6 +3,10 @@ on: push: branches: - main + +permissions: + contents: read # only needs to check out the repo to build & deploy + jobs: deploy: runs-on: warp-ubuntu-latest-arm64-4x diff --git a/.github/workflows/trigger-tasks-deploy-release.yml b/.github/workflows/trigger-tasks-deploy-release.yml index 2b066c359b..30c922bac7 100644 --- a/.github/workflows/trigger-tasks-deploy-release.yml +++ b/.github/workflows/trigger-tasks-deploy-release.yml @@ -5,6 +5,9 @@ on: branches: - release +permissions: + contents: read # only needs to check out the repo to build & deploy + jobs: deploy: runs-on: warp-ubuntu-latest-arm64-4x diff --git a/apps/api/package.json b/apps/api/package.json index 9db847a0b7..423679e678 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -61,7 +61,7 @@ "@aws-sdk/lib-storage": "3.1013.0", "@aws-sdk/s3-request-presigner": "3.1013.0", "@browserbasehq/sdk": "2.6.0", - "@browserbasehq/stagehand": "^3.2.1", + "@browserbasehq/stagehand": "^3.7.0", "@inference/tracing": "^0.0.21", "@maced/api-client": "^0.9.2", "@mendable/firecrawl-js": "^4.9.3", @@ -69,7 +69,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.1.5", - "@nestjs/swagger": "^11.2.0", + "@nestjs/swagger": "^11.4.5", "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "7.6.0", "@prisma/client": "7.6.0", @@ -92,8 +92,8 @@ "adm-zip": "^0.5.16", "ai": "^6.0.175", "archiver": "^7.0.1", - "axios": "^1.12.2", - "better-auth": "^1.4.22", + "axios": "^1.16.0", + "better-auth": "^1.6.13", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "docx": "^9.7.1", diff --git a/apps/api/src/framework-editor/constants.ts b/apps/api/src/framework-editor/constants.ts new file mode 100644 index 0000000000..39161f36d3 --- /dev/null +++ b/apps/api/src/framework-editor/constants.ts @@ -0,0 +1,11 @@ +/** + * Shared framework-editor DTO limits. + * + * Requirement descriptions carry full regulatory control text, which can be very + * long: NIST SP800-53r5 PL-2 exceeds 6,000 chars and many HITRUST CSF + * requirements run past 70,000 (FRAME-2). The DB column is unbounded `text`; the + * only ceiling is API-side `@MaxLength` validation, so this single constant is + * the source of truth across every requirement-description path (create / + * update / batch-update / framework import) to keep them from drifting. + */ +export const REQUIREMENT_DESCRIPTION_MAX_LENGTH = 100_000; diff --git a/apps/api/src/framework-editor/framework/dto/import-framework.dto.spec.ts b/apps/api/src/framework-editor/framework/dto/import-framework.dto.spec.ts index fc52ec406a..b140716d75 100644 --- a/apps/api/src/framework-editor/framework/dto/import-framework.dto.spec.ts +++ b/apps/api/src/framework-editor/framework/dto/import-framework.dto.spec.ts @@ -64,23 +64,25 @@ describe('ImportFrameworkDto', () => { expect(await validatePayload(basePayload())).toHaveLength(0); }); - // Bug A — the import path must allow the same 10,000-char requirement - // descriptions the standalone requirement editor allows (NIST PL-2 > 6000). - it('accepts a 10,000-char requirement description', async () => { + // Bug A — the import path must allow the same 100,000-char requirement + // descriptions the standalone requirement editor allows (FRAME-2: NIST PL-2 + // > 6000, HITRUST CSF requirements exceed 70,000). + it('accepts a 100,000-char requirement description', async () => { expect( - await validatePayload(basePayload({ requirementDescription: 'x'.repeat(10_000) })), + await validatePayload(basePayload({ requirementDescription: 'x'.repeat(100_000) })), ).toHaveLength(0); }); - it('rejects a requirement description longer than 10,000 chars', async () => { + it('rejects a requirement description longer than 100,000 chars', async () => { const messages = await validatePayload( - basePayload({ requirementDescription: 'x'.repeat(10_001) }), + basePayload({ requirementDescription: 'x'.repeat(100_001) }), ); - expect(messages.some((m) => m.includes('10000'))).toBe(true); + expect(messages.some((m) => m.includes('100000'))).toBe(true); }); - // Import is the bulk-load path for externally-authored frameworks (NIST), so - // every description field accepts 10,000 — not just requirements. + // FRAME-2 raised requirement descriptions to 100,000, but control / policy / + // task template descriptions keep their existing 10,000 cap — the ticket is + // requirements-only, so this divergence is deliberate. it('accepts 10,000-char control / policy / task descriptions', async () => { const long = 'x'.repeat(10_000); const payload = { diff --git a/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts b/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts index a20d31d2f1..621ce62832 100644 --- a/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts +++ b/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts @@ -21,6 +21,7 @@ import { } from '@db'; import { MaxJsonSize } from '../../validators/max-json-size.validator'; import { IsObjectOrArray } from '../../validators/is-object-or-array.validator'; +import { REQUIREMENT_DESCRIPTION_MAX_LENGTH } from '../../constants'; class ImportFrameworkMetaDto { @ApiProperty() @@ -60,12 +61,13 @@ class ImportRequirementDto { @MaxLength(255) identifier?: string; - // Matches the standalone requirement DTOs (FRAME-2). NIST SP800-53 control - // text routinely exceeds 5000 chars (e.g. PL-2 > 6000). + // Matches the standalone requirement DTOs (FRAME-2). Regulatory control text + // can be very long: NIST SP800-53r5 PL-2 > 6000 chars, and many HITRUST CSF + // requirements exceed 70,000 — hence the shared 100,000 ceiling. @ApiProperty() @IsString() @IsNotEmpty() - @MaxLength(10000) + @MaxLength(REQUIREMENT_DESCRIPTION_MAX_LENGTH) description: string; @ApiPropertyOptional() diff --git a/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.spec.ts b/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.spec.ts index 668a183cc5..1da1c394bb 100644 --- a/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.spec.ts +++ b/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.spec.ts @@ -24,10 +24,10 @@ describe('BatchUpdateRequirementsDto', () => { expect(errors).toHaveLength(0); }); - // ── nested description length (FRAME-2: limit raised 5,000 → 10,000) ─ - it('accepts a nested description at the 10,000-char limit', async () => { + // ── nested description length (FRAME-2: limit raised 10,000 → 100,000) ─ + it('accepts a nested description at the 100,000-char limit', async () => { const dto = toDto({ - updates: [{ id: 'frq_1', description: 'x'.repeat(10_000) }], + updates: [{ id: 'frq_1', description: 'x'.repeat(100_000) }], }); const errors = await validate(dto, { whitelist: true, @@ -36,9 +36,9 @@ describe('BatchUpdateRequirementsDto', () => { expect(errors).toHaveLength(0); }); - it('rejects a nested description longer than 10,000 chars', async () => { + it('rejects a nested description longer than 100,000 chars', async () => { const dto = toDto({ - updates: [{ id: 'frq_1', description: 'x'.repeat(10_001) }], + updates: [{ id: 'frq_1', description: 'x'.repeat(100_001) }], }); const errors = await validate(dto, { whitelist: true, diff --git a/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts b/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts index bea2a7b885..733c45c1aa 100644 --- a/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts +++ b/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts @@ -10,6 +10,7 @@ import { Min, ValidateNested, } from 'class-validator'; +import { REQUIREMENT_DESCRIPTION_MAX_LENGTH } from '../../constants'; class BatchUpdateRequirementItem { @ApiProperty() @@ -32,7 +33,7 @@ class BatchUpdateRequirementItem { @ApiProperty() @IsString() @IsOptional() - @MaxLength(10000) + @MaxLength(REQUIREMENT_DESCRIPTION_MAX_LENGTH) description?: string; @ApiProperty() diff --git a/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.spec.ts b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.spec.ts index 41576de3b0..88551360da 100644 --- a/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.spec.ts +++ b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.spec.ts @@ -28,9 +28,9 @@ describe('CreateRequirementDto', () => { expect(errors).toHaveLength(0); }); - // ── description length (FRAME-2: limit raised 5,000 → 10,000) ────── - it('accepts a description at the 10,000-char limit', async () => { - const dto = toDto({ ...VALID_BASE, description: 'x'.repeat(10_000) }); + // ── description length (FRAME-2: limit raised 10,000 → 100,000) ──── + it('accepts a description at the 100,000-char limit', async () => { + const dto = toDto({ ...VALID_BASE, description: 'x'.repeat(100_000) }); const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true, @@ -38,8 +38,8 @@ describe('CreateRequirementDto', () => { expect(errors).toHaveLength(0); }); - it('rejects a description longer than 10,000 chars', async () => { - const dto = toDto({ ...VALID_BASE, description: 'x'.repeat(10_001) }); + it('rejects a description longer than 100,000 chars', async () => { + const dto = toDto({ ...VALID_BASE, description: 'x'.repeat(100_001) }); const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true, diff --git a/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts index 479f379e26..e7b8ffd4d7 100644 --- a/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts +++ b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { REQUIREMENT_DESCRIPTION_MAX_LENGTH } from '../../constants'; export class CreateRequirementDto { @ApiProperty({ example: 'frk_abc123' }) @@ -22,7 +23,7 @@ export class CreateRequirementDto { @ApiProperty({ example: 'Control environment requirements' }) @IsString() - @MaxLength(10000) + @MaxLength(REQUIREMENT_DESCRIPTION_MAX_LENGTH) description: string; @ApiPropertyOptional({ example: 'Access Control' }) diff --git a/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.spec.ts b/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.spec.ts index 83246f17ed..66f70add13 100644 --- a/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.spec.ts +++ b/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.spec.ts @@ -22,9 +22,9 @@ describe('UpdateRequirementDto', () => { expect(errors).toHaveLength(0); }); - // ── description length (FRAME-2: limit raised 5,000 → 10,000) ────── - it('accepts a description at the 10,000-char limit', async () => { - const dto = toDto({ description: 'x'.repeat(10_000) }); + // ── description length (FRAME-2: limit raised 10,000 → 100,000) ──── + it('accepts a description at the 100,000-char limit', async () => { + const dto = toDto({ description: 'x'.repeat(100_000) }); const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true, @@ -32,8 +32,8 @@ describe('UpdateRequirementDto', () => { expect(errors).toHaveLength(0); }); - it('rejects a description longer than 10,000 chars', async () => { - const dto = toDto({ description: 'x'.repeat(10_001) }); + it('rejects a description longer than 100,000 chars', async () => { + const dto = toDto({ description: 'x'.repeat(100_001) }); const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true, diff --git a/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts b/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts index 367bcdc0e3..1033984154 100644 --- a/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts +++ b/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { REQUIREMENT_DESCRIPTION_MAX_LENGTH } from '../../constants'; export class UpdateRequirementDto { @ApiPropertyOptional() @@ -17,7 +18,7 @@ export class UpdateRequirementDto { @ApiPropertyOptional() @IsString() @IsOptional() - @MaxLength(10000) + @MaxLength(REQUIREMENT_DESCRIPTION_MAX_LENGTH) description?: string; @ApiPropertyOptional() diff --git a/apps/app/package.json b/apps/app/package.json index 29da583801..6af85f8766 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -18,7 +18,7 @@ "@aws-sdk/s3-request-presigner": "3.1013.0", "@azure/core-rest-pipeline": "^1.21.0", "@browserbasehq/sdk": "2.6.0", - "@browserbasehq/stagehand": "^3.0.5", + "@browserbasehq/stagehand": "^3.7.0", "@date-fns/tz": "^1.2.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -85,8 +85,8 @@ "@xyflow/react": "^12.10.0", "ai": "^6.0.175", "ai-elements": "^1.6.1", - "axios": "^1.9.0", - "better-auth": "^1.4.22", + "axios": "^1.16.0", + "better-auth": "^1.6.13", "botid": "^1.5.5", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", @@ -102,7 +102,7 @@ "mammoth": "^1.11.0", "motion": "^12.35.0", "nanoid": "^5.1.6", - "next": "^16.2.0", + "next": "^16.2.6", "next-safe-action": "^8.0.3", "next-themes": "^0.4.4", "nuqs": "^2.4.3", @@ -171,7 +171,7 @@ "trigger.dev": "4.4.3", "typescript": "^5.8.3", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "^3.2.6" }, "exports": { "./src/lib/encryption": "./src/lib/encryption.ts" diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx index 107e837068..c4cbbc5c47 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx @@ -20,11 +20,18 @@ interface OrgChartData { interface OrgChartContentProps { chartData: OrgChartData | null; members: OrgChartMember[]; + onChartChange: () => void | Promise; } -export function OrgChartContent({ chartData, members }: OrgChartContentProps) { +export function OrgChartContent({ + chartData, + members, + onChartChange, +}: OrgChartContentProps) { if (!chartData) { - return ; + return ( + + ); } if (chartData.type === 'uploaded' && chartData.signedImageUrl) { @@ -32,6 +39,7 @@ export function OrgChartContent({ chartData, members }: OrgChartContentProps) { ); } @@ -57,6 +65,7 @@ export function OrgChartContent({ chartData, members }: OrgChartContentProps) { initialEdges={chartData.edges} members={members} updatedAt={chartData.updatedAt ?? null} + onChartChange={onChartChange} /> ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEditor.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEditor.tsx index 077c2970a4..c2fad40905 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEditor.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEditor.tsx @@ -18,7 +18,6 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { formatDistanceToNow } from 'date-fns'; -import { useRouter } from 'next/navigation'; import { useCallback, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import type { OrgChartMember } from '../types'; @@ -30,6 +29,7 @@ interface OrgChartEditorProps { initialEdges: Edge[]; members: OrgChartMember[]; updatedAt: string | null; + onChartChange: () => void | Promise; } export function OrgChartEditor({ @@ -37,9 +37,9 @@ export function OrgChartEditor({ initialEdges, members, updatedAt, + onChartChange, }: OrgChartEditorProps) { const api = useApi(); - const router = useRouter(); const reactFlowWrapper = useRef(null); const [reactFlowInstance, setReactFlowInstance] = useState(null); @@ -161,7 +161,12 @@ export function OrgChartEditor({ setLastSavedAt(new Date().toISOString()); } toast.success('Org chart saved'); - router.refresh(); + + try { + await onChartChange(); + } catch { + // Save already succeeded; a refresh failure shouldn't surface as a save error. + } } catch { toast.error('Failed to save org chart'); } finally { diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEmptyState.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEmptyState.tsx index e677a87262..2da1050a56 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEmptyState.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEmptyState.tsx @@ -9,9 +9,13 @@ import type { OrgChartMember } from '../types'; interface OrgChartEmptyStateProps { members: OrgChartMember[]; + onChartChange: () => void | Promise; } -export function OrgChartEmptyState({ members }: OrgChartEmptyStateProps) { +export function OrgChartEmptyState({ + members, + onChartChange, +}: OrgChartEmptyStateProps) { const [mode, setMode] = useState<'empty' | 'create' | 'upload'>('empty'); if (mode === 'create') { @@ -21,12 +25,18 @@ export function OrgChartEmptyState({ members }: OrgChartEmptyStateProps) { initialEdges={[]} members={members} updatedAt={null} + onChartChange={onChartChange} /> ); } if (mode === 'upload') { - return setMode('empty')} />; + return ( + setMode('empty')} + onUploaded={onChartChange} + /> + ); } return ( diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartImageView.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartImageView.tsx index 6eeed4d7f0..f0c0bbd3f9 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartImageView.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartImageView.tsx @@ -5,20 +5,20 @@ import { Button } from '@trycompai/design-system'; import { TrashCan, Upload } from '@trycompai/design-system/icons'; import { useApi } from '@/hooks/use-api'; import { toast } from 'sonner'; -import { useRouter } from 'next/navigation'; import { UploadOrgChartDialog } from './UploadOrgChartDialog'; interface OrgChartImageViewProps { imageUrl: string; chartName: string; + onChartChange: () => void | Promise; } export function OrgChartImageView({ imageUrl, chartName, + onChartChange, }: OrgChartImageViewProps) { const api = useApi(); - const router = useRouter(); const [isDeleting, setIsDeleting] = useState(false); const [showReplace, setShowReplace] = useState(false); @@ -33,7 +33,12 @@ export function OrgChartImageView({ } toast.success('Org chart deleted'); - router.refresh(); + + try { + await onChartChange(); + } catch { + // Delete already succeeded; a refresh failure shouldn't surface as a delete error. + } } catch { toast.error('Failed to delete org chart'); } finally { @@ -42,7 +47,12 @@ export function OrgChartImageView({ }; if (showReplace) { - return setShowReplace(false)} />; + return ( + setShowReplace(false)} + onUploaded={onChartChange} + /> + ); } return ( diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartTabContent.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartTabContent.tsx index a0cc7f7129..7a01fc0e3c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartTabContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartTabContent.tsx @@ -10,7 +10,7 @@ interface OrgChartTabContentProps { } export function OrgChartTabContent({ organizationId }: OrgChartTabContentProps) { - const { orgChart } = useOrgChart(); + const { orgChart, mutate } = useOrgChart(); const { members } = useTeamMembers({ organizationId }); const chartMembers: OrgChartMember[] = members @@ -25,5 +25,11 @@ export function OrgChartTabContent({ organizationId }: OrgChartTabContentProps) jobTitle: m.jobTitle ?? null, })); - return ; + return ( + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/UploadOrgChartDialog.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/UploadOrgChartDialog.tsx index 56fa6b55a4..41e5683aea 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/UploadOrgChartDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/UploadOrgChartDialog.tsx @@ -6,15 +6,17 @@ import { Button } from '@trycompai/design-system'; import { Upload, Close } from '@trycompai/design-system/icons'; import { useApi } from '@/hooks/use-api'; import { toast } from 'sonner'; -import { useRouter } from 'next/navigation'; interface UploadOrgChartDialogProps { onClose: () => void; + onUploaded: () => void | Promise; } -export function UploadOrgChartDialog({ onClose }: UploadOrgChartDialogProps) { +export function UploadOrgChartDialog({ + onClose, + onUploaded, +}: UploadOrgChartDialogProps) { const api = useApi(); - const router = useRouter(); const [isUploading, setIsUploading] = useState(false); const [selectedFile, setSelectedFile] = useState(null); @@ -60,8 +62,13 @@ export function UploadOrgChartDialog({ onClose }: UploadOrgChartDialogProps) { } toast.success('Org chart uploaded'); - router.refresh(); onClose(); + + try { + await onUploaded(); + } catch { + // Upload already succeeded; a refresh failure shouldn't surface as an upload error. + } } catch { toast.error('Failed to upload org chart'); } finally { diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/task-automation-actions.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/task-automation-actions.ts index 0e0674d037..e145e1a0f1 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/task-automation-actions.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/task-automation-actions.ts @@ -55,7 +55,15 @@ async function callEnterpriseApi( ): Promise { const { enterpriseApiUrl, enterpriseApiKey } = getEnterpriseConfig(); - const url = new URL(endpoint, enterpriseApiUrl); + const baseUrl = new URL(enterpriseApiUrl); + const url = new URL(endpoint, baseUrl); + + // SSRF guard: `endpoint` and params can carry user-derived values, so pin the + // request to the configured enterprise API origin — never allow it to be + // redirected to another host. + if (url.origin !== baseUrl.origin) { + throw new EnterpriseApiError('Invalid enterprise API endpoint', 400); + } if (options.params) { Object.entries(options.params).forEach(([key, value]) => { @@ -243,8 +251,10 @@ export async function analyzeAutomationWorkflow(scriptContent: string) { export const getAutomationRunStatus = async (runId: string) => { try { - const result = await callEnterpriseApi(`/api/tasks-automations/runs/${runId}`, { - }); + const result = await callEnterpriseApi( + `/api/tasks-automations/runs/${encodeURIComponent(runId)}`, + {}, + ); return { success: true, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/preview/preview.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/preview/preview.tsx index 0f54f92a96..81233c2880 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/preview/preview.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/components/preview/preview.tsx @@ -13,6 +13,24 @@ interface Props { url?: string; } +/** + * Only allow http(s) URLs to reach an iframe `src` or anchor `href`. User-typed + * input flows into the preview iframe, so an unvalidated value like + * `javascript:...` or `data:text/html,...` would execute in the preview context + * (XSS). Returns the normalized href, or undefined if the value isn't http(s). + */ +function toSafeUrl(value: string | undefined | null): string | undefined { + if (!value) return undefined; + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + ? parsed.href + : undefined; + } catch { + return undefined; + } +} + export function Preview({ className, disabled, url }: Props) { const [currentUrl, setCurrentUrl] = useState(url); const [error, setError] = useState(null); @@ -21,36 +39,46 @@ export function Preview({ className, disabled, url }: Props) { const iframeRef = useRef(null); const loadStartTime = useRef(null); + // Sanitized view of currentUrl used for every DOM sink (iframe src, anchor href). + const safeUrl = toSafeUrl(currentUrl); + useEffect(() => { setCurrentUrl(url); setInputValue(url || ''); }, [url]); const refreshIframe = () => { - if (iframeRef.current && currentUrl) { + if (iframeRef.current && safeUrl) { setIsLoading(true); setError(null); loadStartTime.current = Date.now(); iframeRef.current.src = ''; setTimeout(() => { if (iframeRef.current) { - iframeRef.current.src = currentUrl; + iframeRef.current.src = safeUrl; } }, 10); } }; const loadNewUrl = () => { - if (iframeRef.current && inputValue) { - if (inputValue !== currentUrl) { - setIsLoading(true); - setError(null); - loadStartTime.current = Date.now(); - iframeRef.current.src = inputValue; - } else { - refreshIframe(); - } + if (!inputValue) return; + const safeInput = toSafeUrl(inputValue); + if (!safeInput) { + setError('Enter a valid http(s) URL'); + return; } + if (safeInput === safeUrl) { + refreshIframe(); + return; + } + // Drive the iframe through state (not iframeRef.current.src) so the src + // prop, the external-link href, and refresh/try-again all stay in sync + // with the URL actually shown. + setIsLoading(true); + setError(null); + loadStartTime.current = Date.now(); + setCurrentUrl(safeInput); }; const handleIframeLoad = () => { @@ -67,7 +95,12 @@ export function Preview({ className, disabled, url }: Props) {
- +