+
{fw.framework.description?.trim() || '—'}
From 7a08b2c3d4fd411e4c99b8885ba795874f32bceb Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 6 Apr 2026 15:48:17 -0400
Subject: [PATCH 3/4] feat: enhance vendor research and improve ui for it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* chore: add @chenglou/pretext for vendor research feed animations
Co-Authored-By: Claude Opus 4.6 (1M context)
* refactor(vendor): extract shared firecrawl utilities
Co-Authored-By: Claude Sonnet 4.6
* feat(vendor): add research metadata types and data merge helper
Co-Authored-By: Claude Sonnet 4.6
* feat(vendor): add VendorNewsLoadingPlaceholder component
* feat(vendor): add news research firecrawl agent
* feat(vendor): add core research firecrawl agent (certs, links, assessment)
Co-Authored-By: Claude Sonnet 4.6
* feat(vendor): parallel firecrawl calls with trigger.dev metadata progress
Replace the single firecrawl call with parallel core + news research
using Promise.allSettled. Progressive DB writes land core data first,
then merge news. Trigger.dev metadata tracks phase, messages, and
per-agent readiness for real-time frontend consumption.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): add VendorResearchFeed component with animated progress messages
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): add staggered entry animations to risk assessment cards
Co-Authored-By: Claude Sonnet 4.6
* feat(vendor): three-phase rendering with research feed, card reveal, and news arrival
Co-Authored-By: Claude Opus 4.6 (1M context)
* chore(vendor): deprecate single firecrawl agent in favor of parallel core+news agents
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): show research feed during regeneration when assessment already exists
Co-Authored-By: Claude Opus 4.6 (1M context)
* chore(vendor): add structured logging throughout risk assessment task
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): overhaul research feed UI with drip-feed animation and filler messages
- Messages now appear one-at-a-time with 600ms delay between each
- Simulated "scanning..." filler messages keep the feed alive during long Firecrawl pauses
- Gradient header bar, slide-in animations, colored backgrounds for found/error items
- Bouncing dots indicator at the bottom while active
- Bumped news maxCredits from 300 to 500 (was hitting limit)
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use design system color tokens, switch to risk-assessment tab on regenerate
- Replace all raw Tailwind colors (blue-400, emerald-500, etc.) with DS tokens
(primary, success, destructive, muted-foreground, accent-foreground)
- Auto-switch to risk-assessment tab when user clicks Regenerate Assessment
- Make Tabs controlled so we can programmatically switch tabs
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): integrate Pretext for jank-free feed height animation
Use Pretext prepare() + layout() to predict message list height without
DOM measurement. The container animates to the predicted height via CSS
transition, eliminating layout thrash as new messages drip in.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): remove Pretext height prediction causing bounce artifact
The predicted height didn't match actual DOM layout (font metrics mismatch),
causing the container to bounce up then scroll down. Reverted to natural
content flow — the drip-feed + slide-in animations are the real visual effect.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): redesign research feed with rolling window and findings counter
- Only show last 5 messages, older ones fade progressively (like Perplexity)
- 35 unique filler messages picked randomly without repeats
- Live findings counter at bottom ("2 certifications · 3 links · assessment")
- Filler interval reduced to 3.5s for more dynamic feel
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): hide stale certification badges and links during regeneration
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): replace misleading filler messages with honest crawling activity
Filler messages now describe what the agent is doing (reading pages, following
links, crawling subdomains) instead of implying specific findings that haven't
actually been discovered yet.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): show all verified certifications instead of hardcoded whitelist
The certification display was limited to a whitelist of 5 types (SOC 2,
ISO 27001, ISO 42001, HIPAA, ISO 9001), silently dropping valid certs
like FedRAMP, TISAX, C5, ISO 27017/27018/27701, CCPA, CSA, etc.
Now all verified certifications are shown. Known types get normalized to
canonical slugs, unknown types are passed through as-is.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): stagger real findings in feed, remove fake filler messages
Backend now reports each finding individually with real delays between
metadata pushes (700ms between certs, 500ms between links, etc.) so the
UI shows them appearing one by one. Every message is now a real finding
or honest status — no more simulated filler messages.
Frontend simplified to just render metadata messages directly with a
rolling window (last 6 visible, older fade out) and a findings counter.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): remove green background from found items in research feed
The bg-success/10 on every "found" message made the feed look like a
wall of green cards. Green text + checkmark icon is sufficient to
indicate findings without the heavy background highlight.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): replace text feed with radar visualization + finding badges
Animated security radar with sweep line and pulsing blips that appear
as findings are discovered. Certifications show as green badge chips,
links as blue badge chips, organized in labeled sections. Shimmer bar
at top, status text at bottom. Way more visually engaging than the
plain text feed.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): add scanning checklist with pending/done states, remove bottom status text
Right side now shows 4 categories (Certifications, Links, Assessment, News)
each with a spinner → checkmark transition. Badge chips appear under each
category as findings arrive. Removed redundant status text at the bottom.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): visible radar sweep line, centered empty state, responsive layout
- Sonar sweep line now uses 0.8 opacity with gradient trail — visible on
light theme, spins every 2.5s
- Empty state: large centered radar (200px) with horizontal checklist below
- With findings: compact side-by-side (160px radar + badges)
- Radar size prop for responsive sizing between states
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use named CSS keyframes for radar sweep and blip animations
Tailwind arbitrary animation syntax (animate-[spin_2.5s...]) wasn't
rendering. Added proper @keyframes radar-sweep in globals.css and
applied via inline style. Blips now use standard animate-pulse.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): fix invisible radar sweep, reduce finding delays
- Sweep line and shimmer bar now use Tailwind classes (from-primary/80)
instead of hsl(var(--color-primary)) which didn't work with oklch colors
- Blip glow uses theme() shadow instead of hsl
- Reduced sleep delays between findings from 500-800ms to 150-400ms
to eliminate the empty white space gap before badges render
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use Tailwind animate-spin for radar sweep instead of custom keyframes
Custom @keyframes wasn't being applied. Switched to Tailwind's built-in
animate-spin class with animationDuration override to 2.5s.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): replace div-based radar sweep with SVG for pixel-perfect alignment
The div-based sweep line and trail had mismatched transform origins causing
them to spin disconnected. Replaced with a single SVG element containing
both the gradient line and trail cone, sharing the same coordinate space.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): thinner sweep line (1px), darker gradient, narrower trail
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use currentColor for radar sweep line — visible on all themes
theme(colors.primary) wasn't resolving in SVG stop-color. Switched to
currentColor with text-foreground class so the line inherits the dark
foreground color. Removed defs/linearGradient in favor of simple opacity.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): simplify radar sweep to single div line, remove trail cone
SVG trail was misaligned during rotation. Replaced with a simple div
line from center to edge using bg-foreground/40. Clean and guaranteed
to spin correctly since animate-spin rotates the parent container.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use primary color for radar sweep line
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): replace radar with card-based research feed
4 category cards shown immediately as ghost/dashed placeholders with
shimmer skeletons. Each transforms into a filled card with badge chips
as findings arrive. Status pill shows current activity. Never empty,
never stuck — always something visible and animating.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): add animated AI research agent with rotating states
Replaces the static status text pill with an animated agent that cycles
through research actions every 2.5s: searching, reading, analyzing,
checking security, reviewing compliance, following links. Each state
has an emoji icon that rotates in/out with a scale+rotate animation
and a label that slides in from below. Pulsing ring behind the icon.
Always animating, never feels stuck.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): add scanning magnifying glass animation over card grid
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use Motion animate for scanning glass instead of CSS keyframes
CSS @keyframes in globals.css get purged by Tailwind v4. Switched to
Motion's animate prop with keyframe arrays for the 4-position scan
path (top-left → top-right → bottom-right → bottom-left). 6s loop.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): show research feed on page refresh when vendor is in_progress
The page now checks vendor.status from the DB, not just React state.
If vendor is in_progress (research running), the research feed shows
even without a realtime subscription (page was refreshed). The SWR
3-second polling will detect when status changes to assessed and
transition to showing the results.
Also hides stale certification badges and links when vendor is
in_progress (not just during the session that triggered it).
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): center scanning glass on cards, seamless infinite loop
Glass lens now points at card centers using 25%/75% positions with
-15px translate offset. Last keyframe matches first for seamless
loop with linear easing — no pause between cycles.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): equal timing for all 4 scanning glass segments
Each move (right, down, left, up) now gets exactly 25% of the 6s
duration. Previously the last segment got 40% making it feel sluggish.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): highlight card being scanned by magnifying glass
Cards get a shadow + primary ring glow when the scanning glass
hovers over them. Uses a synchronized timer (1.5s per card) matching
the 6s glass animation. Highlight only applies to pending cards —
completed cards keep their solid appearance.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): derive active card from glass position instead of separate timer
Uses Motion's onUpdate callback to read the actual animated top/left
values and derive which quadrant (card) the glass is over. No more
timer synchronization — the highlight follows the glass exactly.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): adjust scanning glass timing for consistent perceived speed
Horizontal moves (wider cards) now get 35% of the duration each,
vertical moves (shorter) get 15%. This compensates for the 2x2 grid
being wider than tall, making the glass appear to move at a constant
speed regardless of direction. Total duration bumped to 8s.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): center scanning glass lens on card centers visually
Increased translate offset to -18px to account for the SVG handle
pulling visual weight below the lens center. Adjusted vertical
positions from 25%/75% to 28%/72% to better match actual card
centers within the grid (accounting for gap and padding).
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): nudge scanning glass higher on cards (22%/68%)
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): scanning glass does small circle on each card before moving on
The glass now arrives at a card center, traces a small circle (4%
radius) clockwise around it like it's examining the card, then travels
to the next card. 25 keyframes total for 4 cards with seamless loop.
68% of time spent scanning (circles), 32% traveling between cards.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use 8-point circle for smooth scanning motion instead of 4-point cross
Linear interpolation between 4 cardinal points (top/right/bottom/left)
creates a diamond shape. Using 8 points at 45° intervals approximates
a proper circle since Motion linearly interpolates between closer points.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): tighter scanning circle with oval shape matching card aspect ratio
Reduced horizontal radius from 4% to 2.5% and vertical from 4% to 3.5%.
The oval shape matches the card aspect ratio so the circle motion looks
proportional and stays well within the card boundaries.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): 16-point circle for smoother scanning motion
Doubled from 8 to 16 points (every 22.5°) for a nearly perfect
circle with linear interpolation.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): compensate for container aspect ratio to make circle visually round
Grid container is ~2x wider than tall, so equal % radii look like a
wide oval. Reduced horizontal radius to 1.8% vs 3.5% vertical to
produce a visually circular scanning motion.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use pixel radius for scanning circle — always perfectly round
Percentage-based radii produced ovals because the container is wider
than tall. Now uses a fixed 20px pixel radius with calc() offsets
from percentage card centers. Circle is always round regardless of
container dimensions.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): smooth transition from circle to travel with easeInOut
Switched from linear to easeInOut easing so the glass gently decelerates
at the end of each circle, smoothly accelerates to travel, and gently
decelerates arriving at the next card. Also rebalanced timing: 12% travel
vs 13% circle (was 8% vs 17%) to reduce the speed contrast.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): smooth circle-to-travel transition, fix hover detection during circle
1. Circle now starts and ends at card center with settle keyframes,
so there's no jump from center → top of circle or circle end → next card.
Flow: arrive center → settle → circle → return to center → settle → travel.
2. Hover detection now parses percentage from calc() strings like
"calc(22% + -20px)" using regex, instead of parseFloat which returned
NaN for calc values and broke the card highlight during circle motion.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): smooth spiral loop — no jumps between center and circle
The scanning path now spirals out from center, loops around at full
radius, and spirals back to center using sin(progress * π) as a
radius ramp factor. This means the path starts and ends exactly at
the card center with zero discontinuity — the glass smoothly flows
from travel → spiral out → circle → spiral in → travel to next card.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): scanning glass only visits pending cards, disappears when all done
The glass now dynamically builds its path from only the cards that
haven't received findings yet. As cards complete, the glass skips them
and only travels between remaining pending cards. When one card is left
it just circles on that card. When all cards are done, the glass
disappears. Animation remounts (key change) when pending set changes
to restart cleanly with the new path.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): one continuous curve — no phase transitions, no jerks
Replaced the phase-based approach (spiral at card + travel to next)
with a single continuous parametric curve. The base position smoothly
eases between card centers using an S-curve, while a sinusoidal overlay
adds one circular loop per card. The glass is always moving AND looping
simultaneously — there are no separate phases, no stops, no velocity
discontinuities. Linear timing since easing is baked into the path.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): revert to 2-phase animation with 32-point circle, no spiral ramp
Reverted the continuous curve approach — it didn't feel smooth either.
Back to separate circle + travel phases but with improvements:
- 32 points for buttery smooth circle (was 16-20)
- Constant radius circle (no spiral ramp that caused stops)
- easeInOut smooths the transition between phases
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): restore spiral ramp (center→circle→center) with 32 points
Brought back the sin(progress * π) radius ramp so the glass smoothly
spirals out from center, reaches full radius at the midpoint, and
spirals back to center. Now with 32 points for a much smoother curve
than the original 16-20 point versions.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): increase scanning circle radius from 18px to 30px
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): center scanning glass vertically on cards (27%/73%)
Moved from 22%/68% to 27%/73% so the circle is visually centered
within each card instead of sitting too high.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): adjust card centers (24%/70%) and reduce circle radius to 25px
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): shift scanning glass up 6px to center lens on cards
The SVG handle extends below the lens, pulling the visual center down.
Increased vertical offset from -18px to -24px so the lens circle
(not the bounding box) is centered on each card.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): reset card centers to true 25%/75% and translate to -20px
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): move circle centers up to 22%/72%
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): move circle centers down to 30%/77% so top of circle reaches card top
The circle radiates 25px above and below the center point. Moving
centers down means the top of the circle arc reaches the upper part
of the card instead of floating above it.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): move circle centers UP to 18%/68% so circle is visually centered on cards
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): show "AI research in progress" badge below vendor description
Pulsing primary dot + text badge appears below the vendor description
when research is active (either from this session or detected via
vendor.status === in_progress on page load).
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): change badge text to "Researching vendor"
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): move "Researching vendor" badge above title, below breadcrumbs
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): move "Researching vendor" badge above breadcrumbs
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): minimal research indicator — small dot + muted text, no pill
Replaced the full-width pill badge with a minimal inline indicator:
tiny pulsing dot + "Researching vendor" in muted text. Subtle and
clean, doesn't dominate the page.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): move research indicator inline next to vendor name
Small "Researching" badge sits right next to the vendor title, where
the certification badges normally appear. Contextual, compact, no
extra line of space. Like GitHub's "Private" badge next to repo names.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): remove pulsating dot from research feed header
The inline badge next to the vendor name already indicates research
is active — the dot in the card header was redundant.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): add timing estimate and leave-page notice below research header
"This may take 1-3 minutes depending on the vendor. You can leave
this page — we'll notify you when it's done."
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use Text secondary variant, add spacing between header and cards
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): tighten gap between research title and description
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): remove em dashes from research timing text
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): correct timing estimate to 1-10 minutes
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use variant="muted" for research description text
DS Text component uses "muted" not "secondary" for subdued text.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): measure card centers dynamically with refs instead of hardcoded %
The scanning glass now uses ResizeObserver + getBoundingClientRect to
measure actual card center positions relative to the grid container.
When cards expand with content, the glass path updates automatically.
No more hardcoded percentage positions that drift when cards resize.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): traverse cards in clockwise visual order (TL→TR→BR→BL)
Grid indices are 0=TL, 1=TR, 2=BL, 3=BR (row-major), but the scanning
glass should move clockwise: 0→1→3→2. Previously went 0→1→2→3 which
caused a diagonal jump from top-right to bottom-left.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): show "Researching" indicator in vendors table for in_progress vendors
Name column: small badge with pulsing dot + "Researching" next to vendor name
Status column: spinner + "Researching..." in primary color
Triggers when vendor.status === 'in_progress' in the DB, which covers
both onboarding assessments and manual regeneration.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): add research indicator to the actual VendorsTable component
The previous commit edited VendorColumns.tsx but the table uses its own
VendorNameCell and VendorStatusCell inside VendorsTable.tsx. Fixed the
correct file — name shows "Researching" badge, status shows spinner.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): remove redundant "Researching..." from status column
The badge on the name is enough. Status column shows the normal
VendorStatus component as before.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): skip audit log for trigger-assessment endpoint
The POST method caused the AuditLogInterceptor to log "Created vendor"
when triggering a risk assessment regeneration. Added @SkipAuditLog()
since triggering an assessment is not a vendor creation — the actual
assessment completion is already logged by the trigger.dev task itself.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): audit log says "Triggered vendor risk assessment" not "Created vendor"
Added extractActionDescription() to audit-log.utils.ts that detects
sub-action POST endpoints (like /trigger-assessment) and returns a
correct description. The interceptor now checks this before falling
back to the generic "Created " description.
Reverted @SkipAuditLog — the action IS logged, just with the right text.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): eliminate flash of old results when clicking regenerate
Two fixes:
1. isRegenerating alone now triggers the research feed immediately,
without waiting for the realtime connection to establish. This
closes the window where old data could flash.
2. setIsRegenerating + setActiveTab now fire BEFORE refreshVendor()
so React batches the state updates and renders the feed before
the SWR refresh brings back old riskAssessmentData.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): bump maxCredits to 2500 (default) for both core and news agents
News was hitting the 500 credit limit on larger vendors like GitHub.
Core was at 700. Both now use Firecrawl's default limit of 2500.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix: remove PrismaInstrumentation from trigger.dev config
Prisma's OpenTelemetry instrumentation was flooding trigger.dev logs
with internal spans (prisma:client:operation, serialize, compile,
db_query) making actual task logs unreadable. Removed the
PrismaInstrumentation — Prisma queries still work, they just
don't emit trace spans to trigger.dev anymore.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): upgrade to spark-1-pro, add schema descriptions, improve prompts
Three improvements to Firecrawl research quality:
1. Upgraded both core and news agents from spark-1-mini to spark-1-pro
(~50% recall vs ~40%, 25% better accuracy for security research)
2. Added description fields to all schema properties so the AI knows
exactly what to extract (e.g. "Direct URL to the certification
report or trust page on the vendor domain")
3. Improved news prompt with prioritized categories (security incidents
first), cap of 10 items, and specific source suggestions
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): rename "Links" to "Security Links" in research feed and assessment view
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): allow third-party trust portal URLs in vendor research
URLs like ghec.github.trust.page were filtered out because the domain
validation only allowed exact vendor domain matches. Now also accepts
URLs on well-known trust portal domains (SafeBase, Vanta, Drata, etc.)
when the vendor name appears in the hostname.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): stop filtering URLs by domain, trust the AI agent
Vendors host trust portals on arbitrary domains (SafeBase, Vanta,
custom domains) that no allowlist can fully cover. The Firecrawl
spark-1-pro agent already has a clear prompt and returns relevant
URLs intentionally. Now we just validate the URL is well-formed
HTTP(S) and let the agent's judgment stand.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): replace text-red-600 with text-destructive in certifications card
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): use primary color for Recent News card instead of success green
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): render news and links as text list instead of badge chips
Certifications and assessment keep the badge chip style (green bg).
News and security links now render as a simple text list in
muted-foreground — looks like actual content, not tags.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): style news/links items as link text (text-primary + hover:underline)
Matches the DS Button variant="link" style: text-primary with
underline-offset-4 and hover:underline.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix(vendor): rewrite prompt to prefer trust portals over docs pages
- Removed the "only return URLs from vendor domain" restriction since
vendors use third-party trust portals (SafeBase, Vanta, etc.)
- Explicitly tells the agent to prefer the dedicated trust portal
where customers request reports, NOT documentation pages
- Lists common trust portal platforms by name so the agent knows
what to look for (trust.page, safebase.io, vanta.com)
- Expanded certification types in the prompt for better discovery
- Updated trust_center_url schema description to reinforce preference
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): make research feed findings clickable with real URLs
Backend now includes URLs in metadata messages for certs, links,
and news items. Frontend renders them as clickable: cert badges
open on click, links/news are anchor tags with hover:underline.
Also added search tip to the prompt: "Try searching ' trust
portal'" to help the agent find trust centers not directly linked
from the vendor site.
Co-Authored-By: Claude Opus 4.6 (1M context)
* feat(vendor): show "+X more" for certifications without icons in title badges
Certs with icons (SOC 2, ISO 27001, ISO 42001, HIPAA) show as badge
icons. Remaining certs without icons (FedRAMP, TISAX, C5, etc.) are
counted and shown as "+X more" text at the end.
Co-Authored-By: Claude Opus 4.6 (1M context)
* fix: address code review findings
Critical fixes:
- Fix failing test: validateVendorUrl no longer filters by domain
- Fix null core result silently marking vendor as "assessed" with no data
— now throws so the catch handler resets status properly
Cleanup:
- Remove unused @chenglou/pretext dependency
- Remove dead CSS keyframes (shimmer-bar, scan-cards, radar-sweep)
- Add url field to frontend ResearchMessage type, remove unsafe cast
- Fix spark-1-pro model param type error (SDK types lag behind API)
Co-Authored-By: Claude Opus 4.6 (1M context)
---------
Co-authored-by: Mariano Fuentes
Co-authored-by: Claude Opus 4.6 (1M context)
---
apps/api/src/audit/audit-log.interceptor.ts | 7 +-
apps/api/src/audit/audit-log.utils.ts | 19 +
.../vendor/vendor-risk-assessment-task.ts | 538 +++++++++++++-----
.../vendor-risk-assessment/description.ts | 19 +-
.../firecrawl-agent-core.ts | 194 +++++++
.../firecrawl-agent-news.ts | 127 +++++
.../firecrawl-agent-shared.ts | 112 ++++
.../vendor-risk-assessment/firecrawl-agent.ts | 149 +++--
.../vendor-risk-assessment/metadata-types.ts | 22 +
.../url-validation.spec.ts | 4 +-
.../vendor-risk-assessment/url-validation.ts | 68 ++-
.../src/trust-portal/trust-access.service.ts | 72 ++-
apps/api/trigger.config.ts | 2 -
.../(overview)/components/VendorColumns.tsx | 24 +-
.../(overview)/components/VendorsTable.tsx | 16 +-
.../components/VendorDetailTabs.tsx | 145 ++++-
.../components/VendorResearchFeed.tsx | 464 +++++++++++++++
.../components/VendorResearchSection.tsx | 13 +-
.../VendorNewsLoadingPlaceholder.tsx | 31 +
...VendorRiskAssessmentCertificationsCard.tsx | 2 +-
.../VendorRiskAssessmentView.tsx | 123 ++--
.../filter-certifications.ts | 50 +-
22 files changed, 1861 insertions(+), 340 deletions(-)
create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts
create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-news.ts
create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-shared.ts
create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/metadata-types.ts
create mode 100644 apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorResearchFeed.tsx
create mode 100644 apps/app/src/components/vendor-risk-assessment/VendorNewsLoadingPlaceholder.tsx
diff --git a/apps/api/src/audit/audit-log.interceptor.ts b/apps/api/src/audit/audit-log.interceptor.ts
index 09aeb3996e..25b7d14f6c 100644
--- a/apps/api/src/audit/audit-log.interceptor.ts
+++ b/apps/api/src/audit/audit-log.interceptor.ts
@@ -24,6 +24,7 @@ import {
type ChangesRecord,
buildChanges,
buildDescription,
+ extractActionDescription,
extractCommentContext,
extractDownloadDescription,
extractEntityId,
@@ -129,6 +130,10 @@ export class AuditLogInterceptor implements NestInterceptor {
responseBody,
requestBody,
);
+ const actionDesc = extractActionDescription(
+ request.url,
+ method,
+ );
const downloadDesc = extractDownloadDescription(
request.url,
method,
@@ -145,7 +150,7 @@ export class AuditLogInterceptor implements NestInterceptor {
(request as { userRoles?: string[] }).userRoles,
);
let descriptionOverride: string | null =
- versionDesc ?? downloadDesc ?? policyActionDesc ?? findingDesc;
+ actionDesc ?? versionDesc ?? downloadDesc ?? policyActionDesc ?? findingDesc;
const isAutomationUpdate = policyActionDesc && /automations/.test(request.url) && method === 'PATCH';
const isAttachmentAction = policyActionDesc && /attachments/.test(request.url);
diff --git a/apps/api/src/audit/audit-log.utils.ts b/apps/api/src/audit/audit-log.utils.ts
index 5c1856ac39..9aa24a4535 100644
--- a/apps/api/src/audit/audit-log.utils.ts
+++ b/apps/api/src/audit/audit-log.utils.ts
@@ -55,6 +55,25 @@ export function extractCommentContext(
return null;
}
+/**
+ * Detects action sub-endpoints (e.g. trigger-assessment) on resources
+ * and returns a human-readable description instead of the generic
+ * "Created " that the POST method would produce.
+ */
+export function extractActionDescription(
+ path: string,
+ method: string,
+): string | null {
+ if (method !== 'POST') return null;
+
+ const pathWithoutQuery = path.split('?')[0]!;
+
+ if (/\/vendors\/[^/]+\/trigger-assessment\/?$/.test(pathWithoutQuery))
+ return 'Triggered vendor risk assessment';
+
+ return null;
+}
+
/**
* Detects download/export GET endpoints and returns a human-readable
* description. Returns null for non-download endpoints.
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
index 05eb458b18..e47601c7fa 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
@@ -10,14 +10,19 @@ import {
import { openai } from '@ai-sdk/openai';
import type { Prisma } from '@db';
import type { Task } from '@trigger.dev/sdk';
-import { logger, queue, schemaTask } from '@trigger.dev/sdk';
+import { logger, metadata, queue, schemaTask } from '@trigger.dev/sdk';
import { generateObject } from 'ai';
import { z } from 'zod';
import { resolveTaskCreatorAndAssignee } from './vendor-risk-assessment/assignee';
import { VENDOR_RISK_ASSESSMENT_TASK_ID } from './vendor-risk-assessment/constants';
-import { buildRiskAssessmentDescription } from './vendor-risk-assessment/description';
-import { firecrawlAgentVendorRiskAssessment } from './vendor-risk-assessment/firecrawl-agent';
+import {
+ buildRiskAssessmentDescription,
+ mergeNewsIntoRiskAssessment,
+} from './vendor-risk-assessment/description';
+import { firecrawlResearchCore } from './vendor-risk-assessment/firecrawl-agent-core';
+import { firecrawlResearchNews } from './vendor-risk-assessment/firecrawl-agent-news';
+import type { ResearchMessage } from './vendor-risk-assessment/metadata-types';
import {
buildFrameworkChecklist,
getDefaultFrameworks,
@@ -356,7 +361,9 @@ function mapCertificationToBadgeType(
}
/**
- * Extract compliance badges from risk assessment data
+ * Extract compliance badges from risk assessment data.
+ * Passes through ALL verified certifications — known types get normalized
+ * to a canonical slug, unknown types are kept as-is.
*/
function extractComplianceBadges(
data: Prisma.InputJsonValue,
@@ -370,18 +377,19 @@ function extractComplianceBadges(
return null;
}
- const badges: Array<{ type: ComplianceBadgeType; verified: boolean }> = [];
- const seenTypes = new Set();
+ const badges: Array<{ type: string; verified: boolean }> = [];
+ const seenTypes = new Set();
for (const cert of parsed.certifications) {
- // Only include verified certifications
if (cert.status !== 'verified') {
continue;
}
- const badgeType = mapCertificationToBadgeType(cert.type);
- if (badgeType && !seenTypes.has(badgeType)) {
- seenTypes.add(badgeType);
+ // Normalize known types to canonical slugs, keep unknown as-is
+ const badgeType =
+ mapCertificationToBadgeType(cert.type) ?? cert.type.trim();
+ if (badgeType && !seenTypes.has(badgeType.toLowerCase())) {
+ seenTypes.add(badgeType.toLowerCase());
badges.push({ type: badgeType, verified: true });
}
}
@@ -479,6 +487,7 @@ export const vendorRiskAssessmentTask: Task<
id: true,
website: true,
status: true,
+ logoUrl: true,
},
});
@@ -664,10 +673,24 @@ export const vendorRiskAssessmentTask: Task<
}
}
- // Still mark the org-specific vendor as assessed
+ // Extract compliance badges and logo from cached GlobalVendors data
+ const cachedBadges = globalVendor?.riskAssessmentData
+ ? extractComplianceBadges(
+ globalVendor.riskAssessmentData as Prisma.InputJsonValue,
+ )
+ : null;
+ const cachedLogoUrl = generateLogoUrl(vendor.website);
+
+ // Still mark the org-specific vendor as assessed, and sync badges/logo
await db.vendor.update({
where: { id: vendor.id },
- data: { status: VendorStatus.assessed },
+ data: {
+ status: VendorStatus.assessed,
+ ...(cachedBadges ? { complianceBadges: cachedBadges } : {}),
+ ...(cachedLogoUrl && !vendor.logoUrl
+ ? { logoUrl: cachedLogoUrl }
+ : {}),
+ },
});
return {
success: true,
@@ -765,151 +788,365 @@ export const vendorRiskAssessmentTask: Task<
const organizationFrameworks = getDefaultFrameworks();
const frameworkChecklist = buildFrameworkChecklist(organizationFrameworks);
- // Do research if needed (vendor doesn't exist, no data, or explicitly requested)
- const research =
- needsResearch && payload.vendorWebsite
- ? await firecrawlAgentVendorRiskAssessment({
- vendorName: payload.vendorName,
- vendorWebsite: payload.vendorWebsite,
- })
- : null;
+ try {
+ // Helper to append a progress message to run metadata
+ const messages: ResearchMessage[] = [];
+ const pushMessage = (text: string, type: ResearchMessage['type'], url?: string) => {
+ const msg: ResearchMessage = { text, type, timestamp: Date.now(), ...url ? { url } : {} };
+ messages.push(msg);
+ metadata.set('messages', messages);
+ };
- const description = buildRiskAssessmentDescription({
- vendorName: payload.vendorName,
- vendorWebsite: payload.vendorWebsite ?? null,
- research,
- frameworkChecklist,
- organizationFrameworks,
+ // Initialize metadata
+ metadata.set('phase', 'starting');
+ metadata.set('messages', []);
+ metadata.set('coreReady', false);
+ metadata.set('newsReady', false);
+
+ metadata.set('phase', 'researching');
+ pushMessage(`Analyzing ${payload.vendorWebsite}...`, 'searching');
+
+ logger.info('🚀 Starting parallel research', {
+ vendor: payload.vendorName,
+ website: payload.vendorWebsite,
+ organizationId: payload.organizationId,
});
- const data = parseRiskAssessmentJson(description);
+ const coreStartedAt = Date.now();
+ const newsStartedAt = Date.now();
- // Upsert GlobalVendors with risk assessment data (shared across all organizations)
- // Version is auto-incremented (v1 -> v2 -> v3, etc.)
- // Concurrency: serialize the final "read latest version + write + bump version" step.
- const lockKey = domain ?? normalizedWebsite;
- const { nextVersion, updatedWebsites } = await withAdvisoryLock({
- lockKey,
- run: async () => {
- const latestGlobalVendors = domain
- ? await db.globalVendors.findMany({
- where: { website: { contains: domain } },
- select: {
- website: true,
- riskAssessmentVersion: true,
- riskAssessmentUpdatedAt: true,
- },
- orderBy: [
- { riskAssessmentUpdatedAt: 'desc' },
- { createdAt: 'desc' },
- ],
- })
- : [];
-
- const currentMax = maxVersion(latestGlobalVendors);
- const computedNext = incrementVersion(currentMax);
- const now = new Date();
-
- if (latestGlobalVendors.length > 0) {
- for (const gv of latestGlobalVendors) {
- await db.globalVendors.update({
- where: { website: gv.website },
- data: {
- company_name: payload.vendorName,
- riskAssessmentData: data,
- riskAssessmentVersion: computedNext,
- riskAssessmentUpdatedAt: now,
- },
- });
+ const sleep = (ms: number) =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+ // Run core research and news research in parallel
+ const [coreResult, newsResult] = await Promise.allSettled([
+ (async () => {
+ pushMessage('Crawling vendor website...', 'searching');
+ logger.info('🔍 Core research started', {
+ vendor: payload.vendorName,
+ website: payload.vendorWebsite,
+ });
+ const result = await firecrawlResearchCore({
+ vendorName: payload.vendorName,
+ vendorWebsite: payload.vendorWebsite!,
+ });
+ const durationMs = Date.now() - coreStartedAt;
+ if (result) {
+ const certCount = result.certifications?.length ?? 0;
+ const verifiedCount =
+ result.certifications?.filter((c) => c.status === 'verified')
+ .length ?? 0;
+ const linkCount = result.links?.length ?? 0;
+ logger.info('✅ Core research completed', {
+ vendor: payload.vendorName,
+ durationMs,
+ certifications: certCount,
+ verifiedCertifications: verifiedCount,
+ links: linkCount,
+ hasAssessment: Boolean(result.securityAssessment),
+ riskLevel: result.riskLevel ?? 'none',
+ });
+
+ // Report each finding individually with delays so the UI
+ // shows them appearing one by one in real time
+ if (result.certifications?.length) {
+ pushMessage('Extracting certifications...', 'analyzing');
+ await sleep(300);
+ for (const cert of result.certifications) {
+ if (cert.status === 'verified') {
+ pushMessage(`Found ${cert.type}`, 'found', cert.url ?? undefined);
+ await sleep(250);
+ }
+ }
}
- return {
- nextVersion: computedNext,
- updatedWebsites: latestGlobalVendors.map((gv) => gv.website),
- };
- }
- await db.globalVendors.upsert({
- where: { website: normalizedWebsite },
- create: {
- website: normalizedWebsite,
- company_name: payload.vendorName,
- riskAssessmentData: data,
- riskAssessmentVersion: computedNext,
- riskAssessmentUpdatedAt: now,
- },
- update: {
- company_name: payload.vendorName,
- riskAssessmentData: data,
- riskAssessmentVersion: computedNext,
- riskAssessmentUpdatedAt: now,
- },
+ if (result.links?.length) {
+ pushMessage('Extracting security and legal links...', 'analyzing');
+ await sleep(300);
+ for (const link of result.links) {
+ pushMessage(`Found ${link.label}`, 'found', link.url);
+ await sleep(200);
+ }
+ }
+
+ if (result.securityAssessment) {
+ pushMessage('Generating security assessment...', 'analyzing');
+ await sleep(400);
+ pushMessage('Security assessment complete', 'found');
+ }
+ } else {
+ logger.warn('⚠️ Core research returned null', {
+ vendor: payload.vendorName,
+ durationMs,
+ });
+ }
+ return result;
+ })(),
+ (async () => {
+ logger.info('📰 News research started', {
+ vendor: payload.vendorName,
+ website: payload.vendorWebsite,
+ });
+ const result = await firecrawlResearchNews({
+ vendorName: payload.vendorName,
+ vendorWebsite: payload.vendorWebsite!,
});
+ const durationMs = Date.now() - newsStartedAt;
+ if (result?.length) {
+ logger.info('✅ News research completed', {
+ vendor: payload.vendorName,
+ durationMs,
+ newsItems: result.length,
+ });
+ // Stagger news reporting
+ pushMessage('Processing recent news...', 'analyzing');
+ await sleep(200);
+ for (const item of result) {
+ pushMessage(`Found: ${item.title}`, 'found', item.url ?? undefined);
+ await sleep(150);
+ }
+ } else {
+ logger.info('📰 News research returned no items', {
+ vendor: payload.vendorName,
+ durationMs,
+ });
+ }
+ return result;
+ })(),
+ ]);
- return {
- nextVersion: computedNext,
- updatedWebsites: [normalizedWebsite],
- };
- },
+ logger.info('🏁 Both research calls settled', {
+ vendor: payload.vendorName,
+ coreStatus: coreResult.status,
+ newsStatus: newsResult.status,
+ coreError:
+ coreResult.status === 'rejected' ? String(coreResult.reason) : null,
+ newsError:
+ newsResult.status === 'rejected' ? String(newsResult.reason) : null,
});
- if (updatedWebsites.length > 1) {
- logger.info('Updated multiple duplicates', {
+ // --- Process core results ---
+ const coreData =
+ coreResult.status === 'fulfilled' ? coreResult.value : null;
+
+ if (coreData) {
+ pushMessage('Writing core research to database...', 'analyzing');
+ logger.info('💾 Writing core data to GlobalVendors', {
vendor: payload.vendorName,
- count: updatedWebsites.length,
- websites: updatedWebsites,
+ domain,
+ normalizedWebsite,
+ });
+
+ const description = buildRiskAssessmentDescription({
+ vendorName: payload.vendorName,
+ vendorWebsite: payload.vendorWebsite ?? null,
+ research: { ...coreData, news: null },
+ frameworkChecklist,
+ organizationFrameworks,
+ });
+ const data = parseRiskAssessmentJson(description);
+
+ // Upsert GlobalVendors (same advisory lock pattern as before)
+ const lockKey = domain ?? normalizedWebsite;
+ const { nextVersion, updatedWebsites } = await withAdvisoryLock({
+ lockKey,
+ run: async () => {
+ const latestGlobalVendors = domain
+ ? await db.globalVendors.findMany({
+ where: { website: { contains: domain } },
+ select: {
+ website: true,
+ riskAssessmentVersion: true,
+ riskAssessmentUpdatedAt: true,
+ },
+ orderBy: [
+ { riskAssessmentUpdatedAt: 'desc' },
+ { createdAt: 'desc' },
+ ],
+ })
+ : [];
+
+ const currentMax = maxVersion(latestGlobalVendors);
+ const computedNext = incrementVersion(currentMax);
+ const now = new Date();
+
+ if (latestGlobalVendors.length > 0) {
+ for (const gv of latestGlobalVendors) {
+ await db.globalVendors.update({
+ where: { website: gv.website },
+ data: {
+ company_name: payload.vendorName,
+ riskAssessmentData: data,
+ riskAssessmentVersion: computedNext,
+ riskAssessmentUpdatedAt: now,
+ },
+ });
+ }
+ return {
+ nextVersion: computedNext,
+ updatedWebsites: latestGlobalVendors.map((gv) => gv.website),
+ };
+ }
+
+ await db.globalVendors.upsert({
+ where: { website: normalizedWebsite },
+ create: {
+ website: normalizedWebsite,
+ company_name: payload.vendorName,
+ riskAssessmentData: data,
+ riskAssessmentVersion: computedNext,
+ riskAssessmentUpdatedAt: now,
+ },
+ update: {
+ company_name: payload.vendorName,
+ riskAssessmentData: data,
+ riskAssessmentVersion: computedNext,
+ riskAssessmentUpdatedAt: now,
+ },
+ });
+
+ return {
+ nextVersion: computedNext,
+ updatedWebsites: [normalizedWebsite],
+ };
+ },
});
- }
- const rawRiskLevel = extractRiskLevel(data);
- const normalizedRiskLevel = await normalizeRiskLevel(rawRiskLevel);
+ logger.info('💾 GlobalVendors upsert complete', {
+ vendor: payload.vendorName,
+ version: nextVersion,
+ updatedWebsites,
+ });
- // Log if risk level is missing (AI fallback already logs for ambiguous values)
- if (!rawRiskLevel) {
- logger.info('No risk level in assessment data, defaulting to medium', {
+ // Extract risk level and badges
+ logger.info('🎯 Normalizing risk level', {
vendor: payload.vendorName,
});
- } else if (normalizedRiskLevel) {
- logger.info('Risk level normalized', {
+ const rawRiskLevel = extractRiskLevel(data);
+ const normalizedRiskLvl = await normalizeRiskLevel(rawRiskLevel);
+ const inherentProbability = mapRiskLevelToLikelihood(normalizedRiskLvl);
+ const inherentImpact = mapRiskLevelToImpact(normalizedRiskLvl);
+ const residualProbability = mapRiskLevelToLikelihood(normalizedRiskLvl);
+ const residualImpact = mapRiskLevelToImpact(normalizedRiskLvl);
+ const complianceBadges = extractComplianceBadges(data);
+ const logoUrl = generateLogoUrl(vendor.website);
+
+ logger.info('📊 Risk level and badges extracted', {
vendor: payload.vendorName,
rawRiskLevel,
- normalizedRiskLevel,
+ normalizedRiskLevel: normalizedRiskLvl,
+ hasBadges: Boolean(complianceBadges),
+ badgeCount: Array.isArray(complianceBadges) ? complianceBadges.length : 0,
+ hasLogo: Boolean(logoUrl),
});
- }
- const inherentProbability = mapRiskLevelToLikelihood(normalizedRiskLevel);
- const inherentImpact = mapRiskLevelToImpact(normalizedRiskLevel);
- const residualProbability = mapRiskLevelToLikelihood(normalizedRiskLevel);
- const residualImpact = mapRiskLevelToImpact(normalizedRiskLevel);
+ // Update vendor with core data (keep status in_progress — news may still be loading)
+ await db.vendor.update({
+ where: { id: vendor.id },
+ data: {
+ inherentProbability,
+ inherentImpact,
+ residualProbability,
+ residualImpact,
+ ...(complianceBadges ? { complianceBadges } : {}),
+ ...(logoUrl ? { logoUrl } : {}),
+ },
+ });
- // Extract compliance badges from risk assessment certifications
- const complianceBadges = extractComplianceBadges(data);
- if (complianceBadges) {
- logger.info('Extracted compliance badges from risk assessment', {
+ metadata.set('phase', 'core_complete');
+ metadata.set('coreReady', true);
+
+ logger.info('🎉 Core phase complete — vendor updated, metadata.coreReady=true', {
vendor: payload.vendorName,
- badges: complianceBadges,
+ vendorId: vendor.id,
+ version: nextVersion,
});
- }
- // Generate logo URL from website using Google Favicon API
- const logoUrl = generateLogoUrl(vendor.website);
+ // --- Process news results (merge into existing data) ---
+ const newsData =
+ newsResult.status === 'fulfilled' ? newsResult.value : null;
+
+ if (newsData && newsData.length > 0) {
+ pushMessage('Adding news to research data...', 'analyzing');
+
+ await withAdvisoryLock({
+ lockKey,
+ run: async () => {
+ // Read current data, merge news, write back
+ const websites =
+ updatedWebsites.length > 0
+ ? updatedWebsites
+ : [normalizedWebsite];
+ for (const website of websites) {
+ const gv = await db.globalVendors.findUnique({
+ where: { website },
+ select: { riskAssessmentData: true },
+ });
+ if (!gv?.riskAssessmentData) continue;
+
+ const existingParsed = gv.riskAssessmentData as Record<
+ string,
+ unknown
+ >;
+ const existingTyped =
+ existingParsed as unknown as import('./vendor-risk-assessment/agent-types').VendorRiskAssessmentDataV1;
+ const merged = mergeNewsIntoRiskAssessment(
+ existingTyped,
+ newsData,
+ );
+
+ await db.globalVendors.update({
+ where: { website },
+ data: {
+ riskAssessmentData: JSON.parse(JSON.stringify(merged)),
+ },
+ });
+ }
+ },
+ });
+
+ metadata.set('newsReady', true);
+ logger.info('📰 News merged into GlobalVendors — metadata.newsReady=true', {
+ vendor: payload.vendorName,
+ vendorId: vendor.id,
+ newsCount: newsData.length,
+ websites: updatedWebsites.length > 0 ? updatedWebsites : [normalizedWebsite],
+ });
+ } else if (newsResult.status === 'rejected') {
+ pushMessage('News research could not be completed', 'error');
+ logger.warn('News research failed, continuing with core data only', {
+ vendor: payload.vendorName,
+ error:
+ newsResult.reason instanceof Error
+ ? newsResult.reason.message
+ : String(newsResult.reason),
+ });
+ }
+ } else {
+ // Core research failed
+ if (coreResult.status === 'rejected') {
+ pushMessage('Research encountered an issue', 'error');
+ metadata.set('phase', 'failed');
+ throw coreResult.reason;
+ }
+ // Core returned null (API key missing, invalid URL, etc.)
+ pushMessage('Could not complete research for this vendor', 'error');
+ metadata.set('phase', 'failed');
+ throw new Error(
+ `Core research returned null for ${payload.vendorName} — vendor will not be marked as assessed`,
+ );
+ }
- // Mark org-specific vendor as assessed
+ // Mark vendor as assessed and flip verify task
+ logger.info('🏷️ Setting vendor status to assessed', {
+ vendor: payload.vendorName,
+ vendorId: vendor.id,
+ });
await db.vendor.update({
where: { id: vendor.id },
- data: {
- status: VendorStatus.assessed,
- inherentProbability,
- inherentImpact,
- residualProbability,
- residualImpact,
- // Only set complianceBadges if we found any, otherwise leave unchanged
- ...(complianceBadges ? { complianceBadges } : {}),
- // Only set logoUrl if we generated one, otherwise leave unchanged
- ...(logoUrl ? { logoUrl } : {}),
- },
+ data: { status: VendorStatus.assessed },
});
- // Flip verify task to "todo" once the risk assessment is ready (only if it wasn't already completed/canceled).
await db.taskItem.updateMany({
where: {
id: verifyTaskItemId,
@@ -919,25 +1156,58 @@ export const vendorRiskAssessmentTask: Task<
status: TaskItemStatus.todo,
description:
'Review the latest Risk Assessment and confirm it is accurate.',
- // Keep stable assignee/creator
assigneeId: assigneeMemberId,
updatedById: creatorMemberId,
},
});
- logger.info('✅ COMPLETED', {
+ metadata.set('phase', 'complete');
+
+ logger.info('✅ COMPLETED — all phases done', {
vendor: payload.vendorName,
- researched: Boolean(research),
- version: nextVersion,
+ vendorId: vendor.id,
+ researched: Boolean(coreData),
+ hasNews: newsResult.status === 'fulfilled' && Boolean(newsResult.value),
+ coreStatus: coreResult.status,
+ newsStatus: newsResult.status,
});
return {
success: true,
vendorId: vendor.id,
deduped: false,
- researched: Boolean(research),
- riskAssessmentVersion: nextVersion,
+ researched: Boolean(coreData),
+ riskAssessmentVersion: coreData ? 'latest' : null,
verifyTaskItemId,
};
+ } catch (error) {
+ // Reset vendor status so the UI no longer shows an infinite loading state.
+ // The user can retry later once the underlying issue is resolved.
+ logger.error('❌ Risk assessment failed, resetting vendor status', {
+ vendor: payload.vendorName,
+ vendorId: vendor.id,
+ error: error instanceof Error ? error.message : String(error),
+ });
+
+ await db.vendor.update({
+ where: { id: vendor.id },
+ data: { status: VendorStatus.assessed },
+ });
+
+ // Also reset the verify task back to todo so it doesn't stay stuck
+ if (typeof verifyTaskItemId === 'string') {
+ await db.taskItem.updateMany({
+ where: {
+ id: verifyTaskItemId,
+ status: {
+ notIn: [TaskItemStatus.done, TaskItemStatus.canceled],
+ },
+ },
+ data: { status: TaskItemStatus.todo },
+ });
+ }
+
+ throw error; // Re-throw so trigger.dev still records the failure and retries
+ }
},
});
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts
index 017e4d10f3..d19f6aae86 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts
@@ -1,5 +1,8 @@
import type { OrgFramework } from './frameworks';
-import type { VendorRiskAssessmentDataV1 } from './agent-types';
+import type {
+ VendorRiskAssessmentDataV1,
+ VendorRiskAssessmentNewsItem,
+} from './agent-types';
export function buildRiskAssessmentDescription(params: {
vendorName: string;
@@ -36,3 +39,17 @@ export function buildRiskAssessmentDescription(params: {
(base.securityAssessment ?? '') + checklistSuffix || null,
} satisfies VendorRiskAssessmentDataV1);
}
+
+/**
+ * Merge news items into an existing risk assessment data object.
+ * Used when core research completes first and news arrives later.
+ */
+export function mergeNewsIntoRiskAssessment(
+ existing: VendorRiskAssessmentDataV1,
+ news: VendorRiskAssessmentNewsItem[],
+): VendorRiskAssessmentDataV1 {
+ return {
+ ...existing,
+ news: news.length > 0 ? news : existing.news,
+ };
+}
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts
new file mode 100644
index 0000000000..a2e366fdfc
--- /dev/null
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts
@@ -0,0 +1,194 @@
+// apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts
+import { logger } from '@trigger.dev/sdk';
+import { vendorRiskAssessmentAgentSchema } from './agent-schema';
+import type { VendorRiskAssessmentDataV1 } from './agent-types';
+import { validateVendorUrl } from './url-validation';
+import {
+ type FirecrawlSetup,
+ handleFirecrawlError,
+ normalizeIso,
+ setupFirecrawlClient,
+} from './firecrawl-agent-shared';
+
+export async function firecrawlResearchCore(params: {
+ vendorName: string;
+ vendorWebsite: string;
+}): Promise | null> {
+ const setup = setupFirecrawlClient(params);
+ if (!setup) return null;
+
+ const { firecrawlClient, vendorDomain, seedUrls } = setup;
+ const { vendorName, vendorWebsite } = params;
+
+ const prompt = `Complete cyber security research on the vendor "${vendorName}" with website ${vendorWebsite}.
+
+Extract the following information:
+
+1. **Certifications**: Find all security and compliance certifications. For each one found, determine:
+ - The type of certification (SOC 2 Type I, SOC 2 Type II, ISO 27001, ISO 27017, ISO 27018, ISO 27701, ISO 42001, FedRAMP, HIPAA, PCI DSS, GDPR, TISAX, CSA STAR, C5, SOC 1, SOC 3, etc.)
+ - Whether it's currently active/verified, expired, or not certified
+ - Any issue or expiry dates mentioned
+ - Direct link to the certification report or trust page
+
+2. **Security & Legal Links**: Find the direct URLs to these pages. IMPORTANT: Many vendors host their trust portal on a third-party platform (e.g., SafeBase at trust.page, Vanta, Drata, Whistic). Prefer the actual trust portal where customers can request security reports over documentation pages that just describe compliance processes.
+ - **Trust Center / Security Portal**: The page where customers can review security posture and request compliance reports. This is NOT the docs page about security — it's the dedicated trust portal. Look for links labeled "Trust Center", "Security", "Trust Portal" in the site navigation or footer. It may be hosted on a subdomain (trust.${vendorDomain}, security.${vendorDomain}) or a third-party domain (e.g., ${vendorName.toLowerCase()}.trust.page, ${vendorName.toLowerCase()}.safebase.io). TIP: Try searching "${vendorName} trust portal" or "${vendorName} security trust center" to find it if not immediately visible on the site.
+ - **Privacy Policy**: Usually at /privacy or /privacy-policy
+ - **Terms of Service**: Usually at /terms or /tos
+ - **Security Overview**: A page describing security practices (this CAN be a docs page)
+ - **SOC 2 Report**: Direct link to request or download the SOC 2 report
+
+3. **Summary**: Provide an overall assessment of the vendor's security posture based on your findings.
+
+Focus on the official website ${vendorWebsite} and its trust/security/compliance pages.`;
+
+ let agentResponse;
+ try {
+ agentResponse = await firecrawlClient.agent({
+ prompt,
+ urls: seedUrls,
+ strictConstrainToURLs: false,
+ maxCredits: 2500,
+ timeout: 360,
+ pollInterval: 5,
+ ...({ model: 'spark-1-pro' } as Record), // SDK types lag behind API — model is supported but not typed yet
+ schema: {
+ type: 'object',
+ properties: {
+ risk_level: {
+ type: 'string',
+ description: 'Overall vendor risk level: critical, high, medium, low, or very_low',
+ },
+ security_assessment: {
+ type: 'string',
+ description: 'A detailed paragraph summarizing the vendor security posture, including strengths, weaknesses, and key findings',
+ },
+ last_researched_at: {
+ type: 'string',
+ description: 'ISO 8601 date of when this research was conducted',
+ },
+ certifications: {
+ type: 'array',
+ description: 'All security and compliance certifications found on the vendor website',
+ items: {
+ type: 'object',
+ properties: {
+ type: {
+ type: 'string',
+ description: 'Certification name, e.g. SOC 2 Type II, ISO 27001, FedRAMP, HIPAA, PCI DSS, GDPR, ISO 42001, ISO 27017, ISO 27018, TISAX, CSA STAR, C5, etc.',
+ },
+ status: {
+ type: 'string',
+ enum: ['verified', 'expired', 'not_certified', 'unknown'],
+ description: 'Whether the certification is currently active/verified, expired, not certified, or unknown',
+ },
+ issued_at: {
+ type: 'string',
+ description: 'ISO 8601 date when the certification was issued, if mentioned',
+ },
+ expires_at: {
+ type: 'string',
+ description: 'ISO 8601 date when the certification expires, if mentioned',
+ },
+ url: {
+ type: 'string',
+ description: 'Direct URL to the certification report or trust page on the vendor domain',
+ },
+ },
+ required: ['type'],
+ },
+ },
+ links: {
+ type: 'object',
+ description: 'Direct URLs to key legal and security pages on the vendor domain',
+ properties: {
+ privacy_policy_url: {
+ type: 'string',
+ description: 'Direct URL to the privacy policy page',
+ },
+ terms_of_service_url: {
+ type: 'string',
+ description: 'Direct URL to the terms of service page',
+ },
+ trust_center_url: {
+ type: 'string',
+ description: 'Direct URL to the trust portal where customers can review security posture and request reports. Prefer the dedicated trust portal (often on trust.page, safebase.io, vanta.com, or a trust. subdomain) over documentation pages.',
+ },
+ security_page_url: {
+ type: 'string',
+ description: 'Direct URL to the security overview or security practices page',
+ },
+ soc2_report_url: {
+ type: 'string',
+ description: 'Direct URL to request or download the SOC 2 report',
+ },
+ },
+ },
+ },
+ required: ['security_assessment'],
+ },
+ });
+ } catch (error) {
+ return handleFirecrawlError(error, { vendorName, vendorWebsite, callType: 'core' });
+ }
+
+ if (!agentResponse.success || agentResponse.status === 'failed') {
+ logger.warn('Firecrawl core research job did not complete successfully', {
+ vendorWebsite,
+ status: agentResponse.status,
+ error: agentResponse.error,
+ });
+ return null;
+ }
+
+ const parsed = vendorRiskAssessmentAgentSchema.safeParse(agentResponse.data);
+ if (!parsed.success) {
+ logger.warn('Firecrawl core research returned invalid data shape', {
+ vendorWebsite,
+ issues: parsed.error.issues,
+ });
+ return null;
+ }
+
+ const links = parsed.data.links ?? null;
+ const linkPairs: Array<{ label: string; url: string }> = [];
+ if (links?.trust_center_url)
+ linkPairs.push({ label: 'Trust & Security', url: links.trust_center_url });
+ if (links?.security_page_url)
+ linkPairs.push({ label: 'Security Overview', url: links.security_page_url });
+ if (links?.soc2_report_url)
+ linkPairs.push({ label: 'SOC 2 Report', url: links.soc2_report_url });
+ if (links?.privacy_policy_url)
+ linkPairs.push({ label: 'Privacy Policy', url: links.privacy_policy_url });
+ if (links?.terms_of_service_url)
+ linkPairs.push({ label: 'Terms of Service', url: links.terms_of_service_url });
+
+ const normalizedLinks = linkPairs
+ .map((l) => ({ ...l, url: validateVendorUrl(l.url, vendorDomain, l.label) }))
+ .filter((l): l is { label: string; url: string } => Boolean(l.url));
+
+ const certifications =
+ parsed.data.certifications?.map((c) => ({
+ type: c.type,
+ status: c.status ?? 'unknown',
+ issuedAt: normalizeIso(c.issued_at ?? null),
+ expiresAt: normalizeIso(c.expires_at ?? null),
+ url: validateVendorUrl(c.url ?? null, vendorDomain, `cert:${c.type}`),
+ })) ?? [];
+
+ logger.info('Firecrawl core research completed', {
+ vendorWebsite,
+ found: { links: normalizedLinks.length, certifications: certifications.length },
+ });
+
+ return {
+ kind: 'vendorRiskAssessmentV1',
+ vendorName,
+ vendorWebsite,
+ lastResearchedAt:
+ normalizeIso(parsed.data.last_researched_at ?? null) ?? new Date().toISOString(),
+ riskLevel: parsed.data.risk_level ?? null,
+ securityAssessment: parsed.data.security_assessment ?? null,
+ certifications: certifications.length > 0 ? certifications : null,
+ links: normalizedLinks.length > 0 ? normalizedLinks : null,
+ };
+}
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-news.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-news.ts
new file mode 100644
index 0000000000..11ca1bff65
--- /dev/null
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-news.ts
@@ -0,0 +1,127 @@
+// apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-news.ts
+import { logger } from '@trigger.dev/sdk';
+import type { VendorRiskAssessmentNewsItem } from './agent-types';
+import {
+ handleFirecrawlError,
+ normalizeIso,
+ normalizeUrl,
+ setupFirecrawlClient,
+} from './firecrawl-agent-shared';
+
+const newsResponseSchema = {
+ type: 'object' as const,
+ properties: {
+ news: {
+ type: 'array' as const,
+ description: 'Recent news articles about the company from the last 12 months, ordered by date descending',
+ items: {
+ type: 'object' as const,
+ properties: {
+ date: {
+ type: 'string' as const,
+ description: 'Publication date in ISO 8601 format (YYYY-MM-DD)',
+ },
+ title: {
+ type: 'string' as const,
+ description: 'Article headline or title',
+ },
+ summary: {
+ type: 'string' as const,
+ description: 'One to two sentence summary of the article content',
+ },
+ source: {
+ type: 'string' as const,
+ description: 'Publication name, e.g. TechCrunch, Reuters, company blog',
+ },
+ url: {
+ type: 'string' as const,
+ description: 'Direct URL to the article',
+ },
+ sentiment: {
+ type: 'string' as const,
+ enum: ['positive', 'negative', 'neutral'],
+ description: 'Whether the news is positive (funding, partnerships), negative (breaches, lawsuits), or neutral',
+ },
+ },
+ required: ['date', 'title'],
+ },
+ },
+ },
+ required: ['news'],
+};
+
+export async function firecrawlResearchNews(params: {
+ vendorName: string;
+ vendorWebsite: string;
+}): Promise {
+ const setup = setupFirecrawlClient(params);
+ if (!setup) return null;
+
+ const { firecrawlClient, origin } = setup;
+ const { vendorName, vendorWebsite } = params;
+
+ const prompt = `Find recent news articles (last 12 months) about the company "${vendorName}" (${vendorWebsite}).
+
+Prioritize these categories (from most to least important):
+1. **Security incidents**: Data breaches, vulnerabilities, security failures, incident reports
+2. **Regulatory & legal**: Lawsuits, fines, regulatory actions, compliance issues, government investigations
+3. **Funding & acquisitions**: Funding rounds, M&A activity, IPO news, valuation changes
+4. **Product & partnerships**: Major product launches, strategic partnerships, platform changes
+5. **Leadership**: C-suite changes, key hires, departures
+
+Search the company's blog, newsroom, press releases, and reputable tech news sources (TechCrunch, Reuters, Bloomberg, The Verge, etc). Return up to 10 most significant items, prioritizing security-relevant news.`;
+
+ let agentResponse;
+ try {
+ agentResponse = await firecrawlClient.agent({
+ prompt,
+ urls: [origin, `${origin}/blog`, `${origin}/newsroom`, `${origin}/press`],
+ strictConstrainToURLs: false,
+ maxCredits: 2500,
+ timeout: 360,
+ pollInterval: 5,
+ ...({ model: 'spark-1-pro' } as Record),
+ schema: newsResponseSchema,
+ });
+ } catch (error) {
+ return handleFirecrawlError(error, { vendorName, vendorWebsite, callType: 'news' });
+ }
+
+ if (!agentResponse.success || agentResponse.status === 'failed') {
+ logger.warn('Firecrawl news research job did not complete successfully', {
+ vendorWebsite,
+ status: agentResponse.status,
+ error: agentResponse.error,
+ });
+ return null;
+ }
+
+ const data = agentResponse.data as { news?: Array> } | undefined;
+ const rawNews = data?.news;
+ if (!Array.isArray(rawNews) || rawNews.length === 0) {
+ logger.info('Firecrawl news research returned no news items', { vendorWebsite });
+ return null;
+ }
+
+ const news = rawNews
+ .flatMap((n) => {
+ const isoDate = normalizeIso(n.date as string | undefined);
+ if (!isoDate) return [];
+ return [{
+ date: isoDate,
+ title: (n.title as string) ?? '',
+ summary: (n.summary as string) ?? null,
+ source: (n.source as string) ?? null,
+ url: normalizeUrl(n.url as string | undefined),
+ sentiment: (n.sentiment as 'positive' | 'negative' | 'neutral') ?? null,
+ }];
+ })
+ .filter(Boolean);
+
+ logger.info('Firecrawl news research completed', {
+ vendorWebsite,
+ found: { news: news.length },
+ });
+
+ return news.length > 0 ? news : null;
+}
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-shared.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-shared.ts
new file mode 100644
index 0000000000..13e53b41ee
--- /dev/null
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-shared.ts
@@ -0,0 +1,112 @@
+import Firecrawl from '@mendable/firecrawl-js';
+import { logger } from '@trigger.dev/sdk';
+import { extractVendorDomain } from './url-validation';
+
+export function normalizeUrl(url: string | null | undefined): string | null {
+ if (!url) return null;
+ const trimmed = url.trim();
+ if (!trimmed || trimmed === '') return null;
+
+ const looksLikeDomain =
+ !/^https?:\/\//i.test(trimmed) &&
+ /^[a-z0-9.-]+\.[a-z]{2,}([/].*)?$/i.test(trimmed);
+ const candidate = looksLikeDomain ? `https://${trimmed}` : trimmed;
+
+ try {
+ const u = new URL(candidate);
+ if (!['http:', 'https:'].includes(u.protocol)) return null;
+ return u.toString();
+ } catch {
+ return null;
+ }
+}
+
+export function normalizeIso(date: string | null | undefined): string | null {
+ if (!date) return null;
+ const trimmed = date.trim();
+ if (!trimmed) return null;
+ const d = new Date(trimmed);
+ if (Number.isNaN(d.getTime())) return null;
+ return d.toISOString();
+}
+
+export type FirecrawlSetup = {
+ firecrawlClient: Firecrawl;
+ origin: string;
+ vendorDomain: string;
+ seedUrls: string[];
+};
+
+export function setupFirecrawlClient(params: {
+ vendorName: string;
+ vendorWebsite: string;
+}): FirecrawlSetup | null {
+ const apiKey = process.env.FIRECRAWL_API_KEY;
+ if (!apiKey) {
+ logger.warn('FIRECRAWL_API_KEY is not configured; skipping vendor research');
+ return null;
+ }
+
+ let origin: string;
+ try {
+ origin = new URL(params.vendorWebsite).origin;
+ } catch {
+ logger.warn('Invalid website URL provided to Firecrawl Agent', {
+ vendorWebsite: params.vendorWebsite,
+ });
+ return null;
+ }
+
+ const vendorDomain = extractVendorDomain(params.vendorWebsite);
+ if (!vendorDomain) {
+ logger.warn('Could not extract vendor domain for URL validation', {
+ vendorWebsite: params.vendorWebsite,
+ });
+ return null;
+ }
+
+ const firecrawlClient = new Firecrawl({ apiKey });
+
+ const seedUrls = [
+ origin,
+ `${origin}/privacy`,
+ `${origin}/privacy-policy`,
+ `${origin}/terms`,
+ `${origin}/terms-of-service`,
+ `${origin}/security`,
+ `${origin}/trust`,
+ `${origin}/legal`,
+ `${origin}/compliance`,
+ ];
+
+ return { firecrawlClient, origin, vendorDomain, seedUrls };
+}
+
+export function handleFirecrawlError(
+ error: unknown,
+ context: { vendorName: string; vendorWebsite: string; callType: string },
+): null {
+ const message = error instanceof Error ? error.message : String(error);
+ const isBillingOrRateLimit =
+ message.includes('402') ||
+ message.includes('429') ||
+ message.includes('Payment Required') ||
+ message.includes('Rate') ||
+ message.includes('Too Many Requests');
+
+ if (isBillingOrRateLimit) {
+ logger.error(`Firecrawl API billing or rate limit error (${context.callType})`, {
+ vendorName: context.vendorName,
+ vendorWebsite: context.vendorWebsite,
+ error: message,
+ });
+ throw error;
+ }
+
+ logger.error(`Firecrawl Agent SDK call failed (${context.callType})`, {
+ vendorName: context.vendorName,
+ vendorWebsite: context.vendorWebsite,
+ error: message,
+ });
+ return null;
+}
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts
index 5605f920a7..1ab6303a1b 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts
@@ -1,3 +1,8 @@
+/**
+ * @deprecated Use firecrawlResearchCore and firecrawlResearchNews instead.
+ * This file is kept temporarily for reference. Safe to delete after verifying
+ * the parallel implementation works correctly in production.
+ */
import Firecrawl from '@mendable/firecrawl-js';
import { logger } from '@trigger.dev/sdk';
import { vendorRiskAssessmentAgentSchema } from './agent-schema';
@@ -109,65 +114,111 @@ Focus on their official website ${vendorWebsite} (especially trust/security/comp
`${origin}/compliance`,
];
- const agentResponse = await firecrawlClient.agent({
- prompt,
- urls: seedUrls,
- strictConstrainToURLs: false, // allow following links from seed URLs, but seeds anchor it to the right domain
- schema: {
- type: 'object',
- properties: {
- risk_level: { type: 'string' },
- security_assessment: { type: 'string' },
- last_researched_at: { type: 'string' },
- certifications: {
- type: 'array',
- items: {
- type: 'object',
- properties: {
- type: { type: 'string' },
- status: {
- type: 'string',
- enum: ['verified', 'expired', 'not_certified', 'unknown'],
+ let agentResponse;
+ try {
+ agentResponse = await firecrawlClient.agent({
+ prompt,
+ urls: seedUrls,
+ strictConstrainToURLs: false,
+ maxCredits: 1000,
+ timeout: 480, // 8 minutes — enough for thorough research, with headroom before trigger.dev's 10min maxDuration
+ pollInterval: 5,
+ schema: {
+ type: 'object',
+ properties: {
+ risk_level: { type: 'string' },
+ security_assessment: { type: 'string' },
+ last_researched_at: { type: 'string' },
+ certifications: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ type: { type: 'string' },
+ status: {
+ type: 'string',
+ enum: ['verified', 'expired', 'not_certified', 'unknown'],
+ },
+ issued_at: { type: 'string' },
+ expires_at: { type: 'string' },
+ url: { type: 'string' },
},
- issued_at: { type: 'string' },
- expires_at: { type: 'string' },
- url: { type: 'string' },
+ required: ['type'],
},
- required: ['type'],
},
- },
- links: {
- type: 'object',
- properties: {
- privacy_policy_url: { type: 'string' },
- terms_of_service_url: { type: 'string' },
- trust_center_url: { type: 'string' },
- security_page_url: { type: 'string' },
- soc2_report_url: { type: 'string' },
- },
- },
- news: {
- type: 'array',
- items: {
+ links: {
type: 'object',
properties: {
- date: { type: 'string' },
- title: { type: 'string' },
- summary: { type: 'string' },
- source: { type: 'string' },
- url: { type: 'string' },
- sentiment: {
- type: 'string',
- enum: ['positive', 'negative', 'neutral'],
+ privacy_policy_url: { type: 'string' },
+ terms_of_service_url: { type: 'string' },
+ trust_center_url: { type: 'string' },
+ security_page_url: { type: 'string' },
+ soc2_report_url: { type: 'string' },
+ },
+ },
+ news: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ date: { type: 'string' },
+ title: { type: 'string' },
+ summary: { type: 'string' },
+ source: { type: 'string' },
+ url: { type: 'string' },
+ sentiment: {
+ type: 'string',
+ enum: ['positive', 'negative', 'neutral'],
+ },
},
+ required: ['date', 'title'],
},
- required: ['date', 'title'],
},
},
+ required: ['security_assessment'],
},
- required: ['security_assessment'],
- },
- });
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : String(error);
+ const isBillingOrRateLimit =
+ message.includes('402') ||
+ message.includes('429') ||
+ message.includes('Payment Required') ||
+ message.includes('Rate') ||
+ message.includes('Too Many Requests');
+
+ if (isBillingOrRateLimit) {
+ // Billing/rate-limit errors — re-throw so the task fails and trigger.dev
+ // sends a Slack notification. The parent try-catch resets the vendor status
+ // so the customer never sees error details, just a normal "assessed" state.
+ logger.error('Firecrawl API billing or rate limit error', {
+ vendorName,
+ vendorWebsite,
+ error: message,
+ });
+ throw error;
+ }
+
+ // Transient errors (network, timeout, etc.) — log and return null so the task
+ // continues with a minimal assessment instead of failing outright.
+ logger.error('Firecrawl Agent SDK call failed', {
+ vendorName,
+ vendorWebsite,
+ error: message,
+ });
+ return null;
+ }
+
+ // Verify the agent job actually completed successfully
+ if (!agentResponse.success || agentResponse.status === 'failed') {
+ logger.warn('Firecrawl agent job did not complete successfully', {
+ vendorWebsite,
+ status: agentResponse.status,
+ error: agentResponse.error,
+ });
+ return null;
+ }
const parsed = vendorRiskAssessmentAgentSchema.safeParse(agentResponse.data);
if (!parsed.success) {
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/metadata-types.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/metadata-types.ts
new file mode 100644
index 0000000000..b6b50bcab1
--- /dev/null
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/metadata-types.ts
@@ -0,0 +1,22 @@
+export type ResearchMessageType = 'searching' | 'found' | 'analyzing' | 'error';
+
+export type ResearchMessage = {
+ text: string;
+ type: ResearchMessageType;
+ timestamp: number;
+ url?: string;
+};
+
+export type ResearchPhase =
+ | 'starting'
+ | 'researching'
+ | 'core_complete'
+ | 'complete'
+ | 'failed';
+
+export type ResearchMetadata = {
+ phase: ResearchPhase;
+ messages: ResearchMessage[];
+ coreReady: boolean;
+ newsReady: boolean;
+};
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.spec.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.spec.ts
index ab46d9fe7f..a026c95cd6 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.spec.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.spec.ts
@@ -104,10 +104,10 @@ describe('validateVendorUrl', () => {
);
});
- it('returns null for URLs from wrong domain', () => {
+ it('accepts URLs from any domain (domain filtering removed — trusts AI agent)', () => {
expect(
validateVendorUrl('https://x.com/privacy', 'wix.com', 'privacy'),
- ).toBe(null);
+ ).toBe('https://x.com/privacy');
});
it('returns null for empty/null input', () => {
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.ts
index ddf755e06d..ee7467936d 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/url-validation.ts
@@ -1,20 +1,54 @@
import { logger } from '@trigger.dev/sdk';
import { getDomain } from 'tldts';
+// Well-known trust portal domains that vendors use to host their security pages
+const TRUSTED_PORTAL_DOMAINS = [
+ 'trust.page', // SafeBase
+ 'vanta.com', // Vanta trust centers
+ 'drata.com', // Drata trust centers
+ 'safebase.io', // SafeBase
+ 'securityscorecard.com',
+ 'whistic.com',
+ 'conveyor.com',
+ 'trustcloud.ai',
+ 'scrut.io',
+ 'tugboatlogic.com',
+ 'laika.com',
+];
+
/**
- * Checks whether a URL belongs to the given vendor domain (including subdomains).
- * For example, if vendorDomain is "wix.com", accepts "wix.com", "www.wix.com",
- * "trust.wix.com", but rejects "x.com" or "notwix.com".
+ * Checks whether a URL belongs to or is related to the given vendor domain.
+ * Accepts:
+ * - Exact domain match: github.com
+ * - Subdomains: trust.github.com, security.github.com
+ * - Third-party trust portals with vendor name in subdomain: ghec.github.trust.page
+ * - Known trust portal domains with vendor name in the path or subdomain
*/
export function isUrlFromVendorDomain(
url: string,
vendorDomain: string,
): boolean {
try {
- const hostname = new URL(url).hostname.toLowerCase();
+ const parsed = new URL(url);
+ const hostname = parsed.hostname.toLowerCase();
const domain = vendorDomain.toLowerCase();
- // Exact match or subdomain match (e.g., trust.wix.com for wix.com)
- return hostname === domain || hostname.endsWith(`.${domain}`);
+ const vendorName = domain.split('.')[0]!; // "github" from "github.com"
+
+ // Direct match: github.com or *.github.com
+ if (hostname === domain || hostname.endsWith(`.${domain}`)) {
+ return true;
+ }
+
+ // Third-party trust portal with vendor name in hostname
+ // e.g., ghec.github.trust.page, github.safebase.io
+ if (hostname.includes(vendorName)) {
+ const isKnownPortal = TRUSTED_PORTAL_DOMAINS.some(
+ (portal) => hostname === portal || hostname.endsWith(`.${portal}`),
+ );
+ if (isKnownPortal) return true;
+ }
+
+ return false;
} catch {
return false;
}
@@ -41,13 +75,14 @@ export function extractVendorDomain(
}
/**
- * Validates and filters a URL, ensuring it belongs to the vendor domain.
- * Returns null (with a warning log) if the URL is from a different domain.
+ * Validates a URL, ensuring it's a well-formed HTTP(S) URL.
+ * No longer filters by domain — the Firecrawl agent is trusted to return
+ * relevant URLs (vendors use custom trust portals on arbitrary domains).
*/
export function validateVendorUrl(
url: string | null | undefined,
- vendorDomain: string,
- label: string,
+ _vendorDomain: string,
+ _label: string,
): string | null {
if (!url) return null;
const trimmed = url.trim();
@@ -62,18 +97,7 @@ export function validateVendorUrl(
try {
const u = new URL(candidate);
if (!['http:', 'https:'].includes(u.protocol)) return null;
- const normalized = u.toString();
-
- if (!isUrlFromVendorDomain(normalized, vendorDomain)) {
- logger.warn('Filtered out URL from wrong domain', {
- vendorDomain,
- label,
- url: normalized,
- });
- return null;
- }
-
- return normalized;
+ return u.toString();
} catch {
return null;
}
diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts
index d784c1b53d..b29de78d15 100644
--- a/apps/api/src/trust-portal/trust-access.service.ts
+++ b/apps/api/src/trust-portal/trust-access.service.ts
@@ -2496,16 +2496,20 @@ export class TrustAccessService {
globalVendors.map((gv) => [gv.website, gv.riskAssessmentData]),
);
- // Add icon URLs to compliance badges and trust portal URL
+ // Enrich vendors with trust portal URL and compliance badges from GlobalVendors
return vendors.map((vendor) => {
// Default to original website URL
let trustPortalUrl: string | null = vendor.website;
+ let badges = vendor.complianceBadges;
- // Try to get trust portal URL from GlobalVendors riskAssessmentData
+ // Enrich from GlobalVendors riskAssessmentData
if (vendor.website) {
const riskData = globalVendorMap.get(vendor.website);
if (riskData && typeof riskData === 'object' && riskData !== null) {
- const links = (riskData as Record).links;
+ const parsed = riskData as Record;
+
+ // Extract trust portal URL
+ const links = parsed.links;
if (Array.isArray(links) && links.length > 0) {
const firstLink = links[0];
if (
@@ -2517,18 +2521,74 @@ export class TrustAccessService {
trustPortalUrl = firstLink.url;
}
}
+
+ // Extract compliance badges from riskAssessmentData when vendor record has none
+ if (!badges || !Array.isArray(badges) || badges.length === 0) {
+ badges = this.extractBadgesFromRiskData(parsed);
+ }
}
}
return {
...vendor,
- complianceBadges: this.formatComplianceBadgeLabels(
- vendor.complianceBadges,
- ),
+ complianceBadges: this.formatComplianceBadgeLabels(badges),
trustPortalUrl,
};
});
}
+ /**
+ * Extract compliance badges from GlobalVendors riskAssessmentData certifications.
+ * Used as fallback when the vendor record has no complianceBadges synced yet.
+ */
+ private extractBadgesFromRiskData(
+ data: Record,
+ ): Array<{ type: string; verified: boolean }> | null {
+ const certs = data.certifications;
+ if (!Array.isArray(certs)) return null;
+
+ const CERT_MAP: Record = {
+ soc2: 'soc2',
+ 'soc 2': 'soc2',
+ iso27001: 'iso27001',
+ 'iso 27001': 'iso27001',
+ iso42001: 'iso42001',
+ 'iso 42001': 'iso42001',
+ gdpr: 'gdpr',
+ hipaa: 'hipaa',
+ pcidss: 'pci_dss',
+ 'pci dss': 'pci_dss',
+ pci_dss: 'pci_dss',
+ nen7510: 'nen7510',
+ 'nen 7510': 'nen7510',
+ iso9001: 'iso9001',
+ 'iso 9001': 'iso9001',
+ };
+
+ const badges: Array<{ type: string; verified: boolean }> = [];
+ const seen = new Set();
+
+ for (const cert of certs) {
+ if (
+ !cert ||
+ typeof cert !== 'object' ||
+ cert.status !== 'verified' ||
+ typeof cert.type !== 'string'
+ )
+ continue;
+
+ const normalized = cert.type.toLowerCase().replace(/[^a-z0-9 _]/g, '');
+ // Use canonical slug for known certs, keep original type for unknown ones
+ const badgeType = CERT_MAP[normalized] ?? cert.type.trim();
+ const key = badgeType.toLowerCase();
+ if (badgeType && !seen.has(key)) {
+ seen.add(key);
+ badges.push({ type: badgeType, verified: true });
+ }
+ }
+
+ return badges.length > 0 ? badges : null;
+ }
+
/**
* Format compliance badges as simple type + label pairs for external rendering.
* Does NOT include branded icons to avoid implying vendors were certified through us.
diff --git a/apps/api/trigger.config.ts b/apps/api/trigger.config.ts
index cf79b1895b..c700d132b1 100644
--- a/apps/api/trigger.config.ts
+++ b/apps/api/trigger.config.ts
@@ -1,4 +1,3 @@
-import { PrismaInstrumentation } from '@prisma/instrumentation';
import { syncVercelEnvVars } from '@trigger.dev/build/extensions/core';
import { defineConfig } from '@trigger.dev/sdk';
import { prismaExtension } from './customPrismaExtension';
@@ -9,7 +8,6 @@ export default defineConfig({
runtime: 'node-22',
project: 'proj_zhioyrusqertqgafqgpj', // API project
logLevel: 'log',
- instrumentations: [new PrismaInstrumentation()],
maxDuration: 300, // 5 minutes
build: {
extensions: [
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx
index 24e9990960..67dba6ef1e 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx
@@ -15,6 +15,7 @@ function VendorNameCell({ row, orgId }: { row: Row; orgId: string })
const status = onboardingStatus[vendorId];
const isPending = row.original.isPending || status === 'pending' || status === 'processing';
const isAssessing = row.original.isAssessing || status === 'assessing';
+ const isResearching = row.original.status === 'in_progress';
const isResolved = row.original.status === 'assessed';
if ((isPending || isAssessing) && !isResolved) {
@@ -25,7 +26,20 @@ function VendorNameCell({ row, orgId }: { row: Row; orgId: string })
);
}
- return
{row.original.name};
+ return (
+
+ {row.original.name}
+ {isResearching && (
+
+
+
+
+
+ Researching
+
+ )}
+
+ );
}
function VendorStatusCell({ row }: { row: Row
}) {
@@ -52,6 +66,14 @@ function VendorStatusCell({ row }: { row: Row }) {
);
}
+ if (row.original.status === 'in_progress') {
+ return (
+