Files
twenty/packages/twenty-apps/internal/people-data-labs
Abdullah. bebe03e453 fix(security): bump tar to 7.5.16 across lockfiles (PAX file smuggling) (#21813)
## fix(security): bump tar to 7.5.16 across lockfiles (PAX file
smuggling)

Resolves [#1472 1474 1476 1479 1481 1483 1485 1487 1489 1491 1493 1496
1498 1505](https://github.com/twentyhq/twenty/security/dependabot/1472
1474 1476 1479 1481 1483 1485 1487 1489 1491 1493 1496 1498 1505).

### What

`tar` (`node-tar`) `<= 7.5.15` applies a PAX size override to
intermediary GNU long-name/long-link headers, causing a tar-parser
interpretation differential (file smuggling). Patched in `7.5.16`.

### Why these alerts

The advisory is scanned across many independent Yarn projects, so it
surfaced as one alert per lockfile: the root `yarn.lock` plus 13
`packages/twenty-apps/**` lockfiles (each pulls `tar` transitively).

### How

- Refreshed `tar` to `7.5.16` in the root and all 13 app lockfiles —
they hold `tar` via `^7.5.x` ranges that already permit it, so this is
an in-range lockfile refresh (no override) via `yarn up -R tar`.
- The root additionally had `tar@7.5.15` exact-pinned by
`@mintlify/previewing`, which has **no upstream fix** (latest `4.0.1163`
still pins `7.5.15`). Added a scoped resolution
`@mintlify/previewing/tar -> ^7.5.16`, **extending the existing scoped
tar resolutions** already used for `@electron/rebuild` and
`@electron/node-gyp`.

### Not included

`seed-dependencies/yarn.lock` (alert #1500) is intentionally excluded:
that lockfile and its checksum constants are already modified by the
open form-data PR, so its `tar` bump will follow separately to avoid a
conflict.

### Verification

- No `tar <= 7.5.15` remains across the root or any app lockfile.
- `yarn install --immutable` passes.
2026-06-19 08:49:07 +02:00
..

People Data Labs enrichment app

Enriches Person and Company records with People Data Labs (PDL) data.

Status: data model + enrichment mapper. This package defines the fields, relation, views, role, and manifest, and implements the enrichment logic functions that call the PDL REST API and map the response onto the standard + pdl* fields. The manual "Enrich" record-action workflows are currently created by hand — automatic post-install seeding is implemented but not wired up (see Seeded workflows).


Enrichment logic functions

enrich-companies / enrich-people (bulk workflow actions, for the manual record action) and enrich-company / enrich-person (single-record functions exposed both as a workflow action and as an AI tool) all delegate to a shared, trigger-agnostic core in src/logic-functions/handlers/:

  • The bulk workflow-action functions accept a list of records ({ records, overrideExistingValues? }), call the PDL bulk Enrichment endpoints (/person/bulk, /company/enrich/bulk), and loop the single-record core over each, aggregating the outcome (total / matched / notFound / skipped / errored); a per-record failure is captured as ERROR without aborting the batch (src/logic-functions/utils/run-batch-enrichment.ts).

  • The single-record functions accept one record ({ recordId, overrideExistingValues? }), call the PDL single-record Enrichment endpoints (/person/enrich, /company/enrichsrc/logic-functions/utils/post-pdl-single-enrich.ts), and return a single EnrichResult (src/logic-functions/utils/run-single-enrichment.ts). They declare both a workflowActionTriggerSettings and a toolTriggerSettings, so one function is usable as a workflow step and as an AI tool.

  • Read the record, guard against re-enriching within a TTL (pdlLastEnrichedAt), pick a match identifier (person: pdlId → LinkedIn → email → name; company: pdlId → domain → name), and call the PDL Person/Company Enrichment API (src/logic-functions/utils/).

  • On a match: fill standard fields only when empty (never clobber user data), always (re)write pdl* fields, and set pdlEnrichmentStatus = MATCHED, pdlLastEnrichedAt, pdlRawPayload (+ pdlLikelihood for Person). PDL 404NOT_FOUND; other errors → ERROR. No identifier / fresh TTL → skipped with no writes.

  • SELECT/MULTI_SELECT values are normalized and dropped if not in the field's option set (src/logic-functions/utils/); the option sets are the same src/constants/*-options.ts the field definitions use.

Run locally: yarn twenty dev:function:exec -n enrich-people -p '{"records":[{"id":"<id>"}]}' (bulk) or yarn twenty dev:function:exec -n enrich-person -p '{"recordId":"<id>"}' (single record).

Billing

Each successful match is billed to the workspace in Twenty credits via chargeCredits (twenty-sdk/billing), mirroring PDL's own model — PDL only consumes a credit on a 200 match, so not_found, errors, and skipped records are free:

  • Person match: 336,000 micro-credits ($0.336 — PDL list price $0.28 + 20% margin)
  • Company match: 120,000 micro-credits ($0.12 — PDL list price $0.10 + 20% margin)

The charge is emitted once per PDL batch call (src/logic-functions/utils/enrich-chunk.ts) with quantity = number of matches and resourceContext pdl/person / pdl/company, at the moment PDL returns — a record whose subsequent write fails is still billed, since the PDL cost was already incurred. Prices live in src/constants/*-match-cost-dollars.ts and the margin in src/constants/billing-margin-multiplier.ts. Billing is non-fatal: a failed charge never fails the enrichment.

Seeded workflows (post-install)

Not currently wired up. post-install.function.ts is a no-op (return { seededWorkflows: [] }); the seeding implementation in src/logic-functions/handlers/post-install.ts (postInstallCore) is not invoked. An app's CoreApiClient only exposes per-object CRUD over the workspace /graphql schema, and the workflow-builder mutations needed to seed a workflow (createWorkflowVersionStep / activateWorkflowVersion) are core resolvers the app surface does not yet expose. Until the SDK exposes them, create the two "Enrich" workflows by hand.

When re-enabled, each workflow is a MANUAL / BULK_RECORDS trigger wired to a single LOGIC_FUNCTION step whose records input is bound to the selected records ({{trigger.companies}} / {{trigger.people}}):

  • Enrich companies — runs enrich-companies over the selected Companies.
  • Enrich people — runs enrich-people over the selected People.

The intended seeding (postInstallCore) resolves each function's runtime id from its universalIdentifier via the metadata API, publishes the version (activateWorkflowVersion), and is idempotent (skips a workflow whose name already exists).

Deferred to a later PR: auto-enrichment triggers (on-create event + cron backfill).


Data-model decisions

Bundle scope

Only the core PDL company fields are defined. Premium / Comprehensive / specialized fields (inferred_revenue, linkedin_follower_count, employee growth/churn/tenure, parent / subsidiary, exec movement, top employers, funding_details, …) are out of scope for this app.

Enums → SELECT / MULTI_SELECT

Every PDL enum that has a canonical file is a SELECT, validated 0-missing/0-extra against PDL schema v34.1:

Field Type Options
pdlSeniority (job_title_levels, array) MULTI_SELECT 10
pdlFundingStages (funding_stages, array) MULTI_SELECT 29
pdlIndustry (industry) SELECT 147
pdlJobTitleSubRole (job_title_sub_role) SELECT 106
pdlJobTitleClass, pdlInferredSalary, pdlSex, pdlCompanyType, pdlSizeRange, pdlLatestFundingStage, pdlLocationContinent, pdlLocationMetro, pdlMicExchange SELECT 5 / 11 / 2 / 6 / 8 / 29 / 7 / 384 / 70
  • Option values are normalized to GraphQL enum names (united statesUNITED_STATES): uppercase, accents stripped, non-alphanumeric → _, digit-leading prefixed.
  • Option universalIdentifiers are unique per field (shared enums like industry, metro, and funding stage get a separate id-set per field).
  • The large option sets (metro-options.ts, industry-options.ts, …) and the UUID registry (universal-identifiers.ts) are generated from the PDL taxonomy and checked in. When regenerating for a newer PDL schema, never change an existing option or field UUID — that orphans stored data; only append ids for new options. select-option-constants.spec.ts guards global UUID uniqueness, value normalization, and per-field id integrity.
  • Stays TEXT (no canonical PDL enum file exists): pdlIndustryDetail (industry_v2), pdlJobOnetCode. PDL location_region has no dedicated field — it fills the state slot of the person pdlLocation ADDRESS composite.

Standard-field mapping

pdl* shadows are removed where an equivalent standard field exists; the mapper writes the standard field instead:

Object Removed shadow → standard target
Person pdlLinkedinUrllinkedinLink, pdlJobTitlejobTitle, pdlFullNamename, pdlWorkEmail/pdlPersonalEmailsemails, pdlMobilePhone/pdlPhoneNumbersphones
Company pdlLinkedinUrllinkedinLink, pdlWebsitedomainName, pdlDisplayNamename

Shadows are kept where no reliable standard field is available: pdlEmployeeCount, pdlTwitterUrl. Trade-off: PDL's work/personal-email and mobile/other-phone distinction is dropped (folded into the standard bags).

Location → ADDRESS composite

  • Company location → the standard address composite (street/city/state/postcode/country/geo).
  • Person has no standard address field → dedicated pdlLocation (ADDRESS).
  • pdlLocationMetro (both) and pdlLocationContinent (company) stay SELECT — ADDRESS has no slot. Trade-off: ADDRESS country is free text, so the country SELECT was dropped.

Current company → standard company

PDL's detected current employer (job_company_*) is resolved to a Company record (find-or-create, matched by pdlId → domain → LinkedIn → name; created with name / domainName / linkedinLink + pdlId / pdlIndustry / pdlSizeRange when none matches) and linked via the standard company relation, fill-only-if-empty — it never overwrites a company the user already set, and the lookup is skipped entirely when the person already has one (no orphan companies).

Company attributes live on the Company record, not denormalized on the Person. The earlier pdlCurrentCompany / pdlCurrentEmployees relation and the six pdlJobCompany* scalar fields were removed as duplicates of the standard company relation and the linked Company's own fields.

Enrichment metadata

  • pdlId — PDL record id (re-enrich by id: more precise than by email).
  • pdlLikelihood (Person, NUMBER) — PDL match confidence 110.
  • pdlEnrichmentStatus (SELECT: MATCHED / NOT_FOUND / ERROR) — distinguishes "no match" from "never tried" (drives re-enrichment scheduling).
  • pdlLastEnrichedAt (DATE_TIME), pdlRawPayload (RAW_JSON, full response).

Other

  • pdlTotalFunding is CURRENCY (mapper must convert the bare USD float → micros).
  • Views: a curated "People Data Labs" TABLE view per object.
  • Role: read/update on Person & Company (object-level; tighten to field-scoped later).

What the mapper does

Orchestration (src/logic-functions/)

  1. Runs from the manual "Enrich" record action (BULK_RECORDS) or the single-record enrich-company / enrich-person functions (as a workflow step or an AI tool).
  2. Calls the PDL Person / Company Enrichment API with PDL_API_KEY, passing a min_likelihood chosen by identifier strength (2 with a strong identifier, 6 for a weaker name-based match; overridable per call).
  3. A match → pdlEnrichmentStatus = MATCHED; PDL 404 / no match → NOT_FOUND; other errors → ERROR. Errored and not-found records are also stamped with pdlLastEnrichedAt so the TTL guard backs off instead of re-submitting them on every run.
  4. TTL guard: skips re-enrichment when pdlLastEnrichedAt is within 7 days (bypass with force), and prefers re-enriching by pdlId.

Field writing

  1. Standard fields are filled only when empty (never clobber user data): Person name, emails, phones, linkedinLink, jobTitle; Company name, domainName, linkedinLink, address. All pdl* fields are (re)written on every match.
  2. SELECT guard: a SELECT/MULTI_SELECT value is written only if its normalized form is in the field's option set; otherwise it is skipped and preserved in pdlRawPayload (handles PDL schema versions newer than the bundled one). job_title_levelspdlSeniority, funding_stagespdlFundingStages.
  3. CURRENCY: total_funding_raised (USD) → { amountMicros: value × 1_000_000, currencyCode: 'USD' }.
  4. ADDRESS: PDL location.* is split into the composite — Company → standard address, Person → pdlLocation.
  5. Current company: job_company_* is resolved to a Company record (find-or-create, matched by pdlId → domain → LinkedIn → name) and linked via the standard company relation (fill-only-if-empty); resolutions are cached within a batch run.
  6. Dates: partial PDL dates (YYYY, YYYY-MM) for job_start_date, last_funding_date, birth_date are expanded and range-validated.
  7. Always sets pdlId, pdlLastEnrichedAt, pdlRawPayload (+ pdlLikelihood for Person).