From a1912adecda60ddb600462b21c55ac0f75ab942f Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Wed, 19 Aug 2026 16:15:33 -0300 Subject: [PATCH 01/19] feat: session transform performance, caching, region handoff and observability The setProfile session transform ran a serial chain of uncached external calls on every session update, regularly exceeding Session Manager's 2s budget on cold pods. This restructures the transform around two-layer caching (per-pod LRU + cross-pod VBase stale-while-revalidate), moves fire-and-forget work off the critical path, and adds gated telemetry so production bottlenecks are diagnosable per account without redeploying. Warm-pod server time drops from ~1240ms to ~50-150ms; a cold pod with a warm VBase goes from ~1870ms to ~950ms. Output verified byte-identical to v3.6.1 with all new flags off. Also fixes a regression where users with an inactive organization but another active one received a 500: the recovery branch still unwrapped the old GraphQL client response shape after the Master Data migration, resolving the organization to undefined. New settings (all default off/safe): deferRegionToCheckoutSession, logSessionPayloads, sessionTimingsSlowThresholdMs, sessionTimingsSampleRate, sessionUserCacheTtlMs. Covered by a new jest suite (41 tests) including a regression test proven to fail against the inactive-organization bug. --- .vtexignore | 2 + CHANGELOG.md | 23 + docs/COST_CENTER_ADDRESS_AND_REGION.md | 2 + docs/OBSERVABILITY.md | 48 + docs/PERFORMANCE_AND_CACHING.md | 89 + docs/README.md | 10 + docs/REGION_RESOLUTION.md | 43 + manifest.json | 30 + node/.vtexignore | 2 + node/__tests__/cache.test.ts | 123 + node/__tests__/checkPermissions.test.ts | 132 + node/__tests__/requestTimings.test.ts | 106 + node/__tests__/setProfile.test.ts | 339 ++ .../staleFromVBaseWhileRevalidate.test.ts | 169 + node/__tests__/stubs/diagnostics.js | 19 + node/__tests__/withRequestTimings.test.ts | 72 + node/index.ts | 6 +- node/jest.config.js | 21 + node/middlewares/withRequestTimings.ts | 77 + node/package.json | 13 +- node/resolvers/Queries/Roles.ts | 9 +- node/resolvers/Routes/index.ts | 398 ++- node/service.json | 6 +- node/services/activeUserCache.ts | 69 + node/services/appSettingsCache.ts | 40 +- node/services/cache.ts | 130 + node/services/organizationsCache.ts | 57 + node/services/regionCache.ts | 43 + node/services/rolesCache.ts | 21 + node/services/salesChannelCache.ts | 38 + node/services/sessionWatcherCache.ts | 32 + node/typings/staleFromVBaseWhileRevalidate.ts | 8 + node/utils/constants.ts | 79 + node/utils/requestTimings.ts | 118 + node/utils/staleFromVBaseWhileRevalidate.ts | 147 + node/yarn.lock | 3114 ++++++++++++++++- vtex.session/configuration.json | 2 +- 37 files changed, 5473 insertions(+), 164 deletions(-) create mode 100644 .vtexignore create mode 100644 docs/OBSERVABILITY.md create mode 100644 docs/PERFORMANCE_AND_CACHING.md create mode 100644 docs/REGION_RESOLUTION.md create mode 100644 node/__tests__/cache.test.ts create mode 100644 node/__tests__/checkPermissions.test.ts create mode 100644 node/__tests__/requestTimings.test.ts create mode 100644 node/__tests__/setProfile.test.ts create mode 100644 node/__tests__/staleFromVBaseWhileRevalidate.test.ts create mode 100644 node/__tests__/stubs/diagnostics.js create mode 100644 node/__tests__/withRequestTimings.test.ts create mode 100644 node/jest.config.js create mode 100644 node/middlewares/withRequestTimings.ts create mode 100644 node/services/activeUserCache.ts create mode 100644 node/services/cache.ts create mode 100644 node/services/organizationsCache.ts create mode 100644 node/services/regionCache.ts create mode 100644 node/services/rolesCache.ts create mode 100644 node/services/salesChannelCache.ts create mode 100644 node/services/sessionWatcherCache.ts create mode 100644 node/typings/staleFromVBaseWhileRevalidate.ts create mode 100644 node/utils/requestTimings.ts create mode 100644 node/utils/staleFromVBaseWhileRevalidate.ts diff --git a/.vtexignore b/.vtexignore new file mode 100644 index 00000000..85393934 --- /dev/null +++ b/.vtexignore @@ -0,0 +1,2 @@ +node/__tests__/ +node/jest.config.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a75cb35..2bd31bf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Two-layer caching (per-pod in-memory LRU + cross-pod VBase stale-while-revalidate) for the data `setProfile` reads on every session transform: app settings, sales channel list, B2B settings, organization, cost center, active user, region lookup, session watcher flag (memory-only, it already lives in VBase) and roles (memory-only, same reason). Warm-pod transform time drops from roughly 1.2s to under 150ms, and a cold pod reads the entry a sibling pod populated instead of paying the origin call. The cost center cache is bounded by bytes rather than entry count, because its documents were measured spanning 400B to 29KB. +- The active-user cache key includes the session's `public.b2bCurrentCostCenter`, which `setCurrentOrganization` writes on every organization switch, so switching organizations invalidates the cache by key instead of waiting out a TTL. TTL configurable through the `sessionUserCacheTtlMs` app setting (0 disables). +- New app setting `deferRegionToCheckoutSession` (default `false`). When enabled, `setProfile` stops calling the checkout regions API and instead publishes the selected cost center address as `public.postalCode` and `public.country`, leaving `public.regionId` untouched so `vtex.checkout-session` resolves `checkout.regionId` itself (it performs the same lookup, cached). Also makes `vtex.search-session` regionalize search with the same address the cart uses. Falls back to resolving the region locally when the cost center address lacks a country or postal code, and stands down when region overwrite is active for the request. +- Session transform telemetry: a `withRequestTimings` middleware logs one `setProfile.timings` line with per-step durations when a request is slow (default threshold 1000ms, configurable via `sessionTimingsSlowThresholdMs`) or sampled (`sessionTimingsSampleRate`, default 0), and always when the transform throws (`failed: true`), so incidents show which dependency degraded without redeploying. +- Per-pod cache hit-rate and size stats logged as `cacheStats` every five minutes, piggybacked on the session transform route. +- New app setting `logSessionPayloads` (default `false`) gating the full request/response session payload log, which previously ran on every transform and included the shopper's email and organization data. +- Errors in the stale-while-revalidate cache layer are now logged (`staleFromVBase.readError`, `saveError`, `revalidateError`) instead of being silently swallowed - in particular a failing origin behind a stale-served cache is now visible. +- Jest test suite (41 tests) covering the caches, the stale-while-revalidate helper, the timings middleware, the `checkPermissions` cache, and `setProfile` behaviors: sales channel deferral, region handoff and its fallbacks, organization-switch cache invalidation, payload log gating, session watcher kill switch, and the inactive-organization recovery path. + +### Changed + +- `setProfile` starts its user-independent lookups (sales channel list, B2B settings, app settings) before the user lookup instead of awaiting everything in one batch, hiding their latency behind the user and organization reads. +- `getMarketingTags` and `generateClUser` no longer block the session transform response: both only feed fire-and-forget cart updates, and `generateClUser` had measured spikes near 1s. The CL profile lookup is also skipped entirely when there is no cart to update. +- The sellers facets branch reuses the already-fetched cached app settings instead of issuing a second, uncached `getAppSettings` call. +- `checkPermissions` resolves the user through a short-lived (60s, memory-only) cache; it is called per request by sibling B2B apps and previously hit Master Data every time. +- Service resources aligned with the rest of the B2B suite: memory 256MB to 1024MB, ttl 60 to 300, timeout 45 to 60 (`b2b-organizations-graphql` and `b2b-checkout-settings` already run this profile). + +### Fixed + +- `setProfile` returned a 500 for any user whose organization is inactive but who has another active organization - the exact path meant to recover them. The recovery branch still unwrapped the response shape of the old GraphQL client (`.data.getOrganizationById`) after the Master Data client migration, resolving `organization` to `undefined` and throwing on `organization.name`. Covered by a regression test proven to fail against the old code. + ## [3.6.1] - 2026-08-11 ### Added diff --git a/docs/COST_CENTER_ADDRESS_AND_REGION.md b/docs/COST_CENTER_ADDRESS_AND_REGION.md index 06562f2e..cf22bc8a 100644 --- a/docs/COST_CENTER_ADDRESS_AND_REGION.md +++ b/docs/COST_CENTER_ADDRESS_AND_REGION.md @@ -2,6 +2,8 @@ This document describes how **Storefront Permissions** handles multiple cost center addresses and optional region overwrite in the `setProfile` session transform. It is intended for developers integrating with the session or debugging region/address behavior. +> For the full picture of who resolves the session region — including the `deferRegionToCheckoutSession` handoff that lets `vtex.checkout-session` resolve it instead of this app, and how it composes with the region overwrite described here — see [Region resolution](REGION_RESOLUTION.md). + ## Overview When a B2B user has a cost center with **multiple addresses**, the storefront may let them choose which address to use for shipping, region (e.g. delivery options, pricing), and document type (e.g. Brazil CPF). In addition, the user may temporarily **override** the region (e.g. “check delivery to another location”) by entering a postal code and country, without changing the cost center’s selected address. This app supports both behaviors in an **opt-in** way via app settings. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md new file mode 100644 index 00000000..6808bee1 --- /dev/null +++ b/docs/OBSERVABILITY.md @@ -0,0 +1,48 @@ +# Observability + +All telemetry goes through `ctx.vtex.logger`, which ships to the platform log pipeline (Splunk / OpenSearch), tagged automatically with `account`, `workspace` and `app@version`. The design goal: **silent when healthy, loud exactly when something is slow or broken** — this route runs on every session transform across ~1k accounts, so one log line per request is not viable. + +## Signals + +| Signal | Level | When | What it tells you | +|---|---|---|---| +| `setProfile.timings` | `warn` | Request slower than the threshold (default 1000ms), **or any request that throws** (`failed: true`) | Per-step durations of every external call, plus `slowestStep`, `totalMs`, `orgId`, `costId`, `hashChanged`. Names the degrading dependency without redeploying anything. | +| `setProfile.timings` | `info` | Random sample of healthy requests (`sessionTimingsSampleRate`, default 0 = off) | Baseline p50/p95 material for dashboards. | +| `cacheStats` | `info` | Once per pod every 5 minutes, piggybacked on the transform route | Hit rate, item count and size per cache — the data for tuning cache bounds. | +| `staleFromVBase.revalidateError` | `error` | Background stale-while-revalidate refresh failed | **Important:** the stale value keeps being served, so nothing else surfaces a dead origin. | +| `staleFromVBase.saveError` | `error` | Cross-pod cache write failed | Pods quietly stop warming each other; origin traffic creeps back up. | +| `staleFromVBase.readError` | `warn` | VBase read failed (request fell back to origin) | Recoverable per request, but a VBase outage shows up here. | +| `setProfile.salesChannelDeferredToBinding` | `info` | Per request when the sales-channel deferral is active | If these *disappear* on an account that should have the flag on, the setting was lost (e.g. after a major version bump). | +| `setProfile.regionDeferredToCheckoutSession` | `info` | Per request when the region handoff is active | Same reasoning as above. | +| `setProfile.*Error` (updateSalesChannel, marketing data, shipping, CL profile, B2B settings...) | `error` | A fire-and-forget cart update failed | These never fail the response, so this is their only trace. | +| `setProfile.body` / `setProfile.output` | `info` | Only when `logSessionPayloads` is enabled | Full session payload in/out. **Contains PII** (shopper email, organization data) and costs two `JSON.stringify` per request — enable per account only during an active investigation, then turn it off. | + +## App settings (all tunable per account, no release needed) + +| Setting | Default | Purpose | +|---|---|---| +| `sessionTimingsSlowThresholdMs` | 1000 | Slow-request threshold for the `warn` timing log | +| `sessionTimingsSampleRate` | 0 | Fraction (0–1) of healthy requests logging timings as `info` | +| `logSessionPayloads` | false | Full payload logging (see PII warning above) | +| `sessionUserCacheTtlMs` | 300000 | Active-user cache TTL; 0 disables | + +Settings propagate within ~10 minutes (memory + VBase cache TTLs). + +## Debugging an incident + +1. **Find the slow/failing requests:** query `setProfile.timings` for the account. `failed: true` entries are transforms that threw; the rest exceeded the threshold. `slowestStep` names the culprit directly — e.g. `getCostCenterById` degrading means `vtex.b2b-organizations` is the problem, not this app. +2. **Need a baseline?** Set `sessionTimingsSampleRate: 0.01` on the affected account. 1% of healthy traffic starts logging timings; compare distributions before/after. +3. **Suspect stale data?** Check `staleFromVBase.revalidateError` — a failing origin behind a warm cache is invisible everywhere else. `cacheStats` shows whether hit rates collapsed (e.g. after a pod scale-up storm). +4. **Need the exact payload?** Enable `logSessionPayloads` on that account, reproduce, disable. Do not leave it on. +5. A hung request never shows as `failed`: Session Manager abandons the transform at 2s while the handler finishes and logs as *slow*. Exceptions show as `failed: true` with the steps completed before death. + +## Suggested alerts (configure in OpenSearch) + +- **Failure rate:** count of `setProfile.timings` with `failed: true`, grouped by account — anything sustained is an incident (this signal caught a production 500 in the inactive-organization path). +- **Slow-rate step change:** volume of `warn` timings per account vs its trailing baseline. +- **Silent origin failure:** any `staleFromVBase.revalidateError` sustained for more than a few minutes. +- **Lost feature flag:** `salesChannelDeferredToBinding` (or `regionDeferredToCheckoutSession`) log volume dropping to zero on an account where the flag should be on — the signature of settings lost on a major version bump. + +## What deliberately does NOT log + +Healthy requests. Per-call log lines were considered and rejected: at this volume they cost more than the calls they measure. The timer accumulates in memory (~20 `Date.now()` calls and one small object per request) and emits a single gated line. Platform-level request logs (status codes, unhandled errors) come from VTEX IO's router for free and are not duplicated here. diff --git a/docs/PERFORMANCE_AND_CACHING.md b/docs/PERFORMANCE_AND_CACHING.md new file mode 100644 index 00000000..0c42e60a --- /dev/null +++ b/docs/PERFORMANCE_AND_CACHING.md @@ -0,0 +1,89 @@ +# Performance and caching in the session transform + +`setProfile` (the `vtex.session` transform) runs on **every session creation and update, several times per storefront navigation, across every account with the B2B Suite installed**. Session Manager gives the transform a hard **2-second budget**; historically the transform chained enough serial external calls that cold pods regularly blew it. This document explains how the transform is structured today, how the caching works, and the rules to follow when changing it — so a future change does not silently reintroduce a regression. + +## Request flow + +The transform is ordered around one principle: **only await what the response actually needs, as late as possible, with everything independent already in flight.** + +1. `getSessionWatcher` (cached, memory-only) — the kill switch. If off, return the empty response. +2. Parse body, resolve email; anonymous sessions return before any other call is made. +3. Kick off (not awaited yet): sales channel list, B2B settings, app settings. +4. `getActiveUserByEmail` (cached) — everything else depends on `orgId`/`costId`. +5. Awaited in parallel: `getOrganization` + `getCostCenterById` (the only two calls that need the user). +6. Await the step-3 promises — by now their latency is hidden behind steps 4–5. +7. Sales channel / region / facets logic; region lookup cached or handed off (see [Region resolution](REGION_RESOLUTION.md)). +8. Fire-and-forget cart updates (`promises` array): marketing data, shipping address, CL profile. **These must never be awaited** — they are why `getMarketingTags` and `generateClUser` are not on the critical path. + +### Rules when touching this flow + +- A new external call must justify its position: does the **response** need its result? If it only feeds a cart update or another side effect, chain it into `promises` instead of awaiting it. +- Every fire-and-forget promise must carry a `.catch` that logs through `ctx.vtex.logger` with a distinct `setProfile.*` message. A promise that can reject unawaited without a catch crashes the worker (unhandled rejection). +- Closures pushed into `promises` must not capture reassigned `let` variables (`user`, `businessName`, ...). Read them into `const`s first — the platform builder compiles stricter than local `tsc` and flags these as implicit `any`. + +## Caching architecture + +All caches are built by `createCachedResource` (`node/services/cache.ts`), with up to two layers: + +1. **Per-pod in-memory LRU** — a warm pod does zero I/O. This is what makes the repeated transforms within one navigation cheap. +2. **Cross-pod VBase stale-while-revalidate** (`node/utils/staleFromVBaseWhileRevalidate.ts`) — on a memory miss, the pod reads the entry a sibling pod populated instead of calling the origin. Stale entries are returned immediately and refreshed in the background, so the origin call never lands on a request after first population. + +**Rule: only add the VBase layer when the origin is expensive** (Apps API, Master Data, another app's GraphQL, checkout). For data that already lives in VBase — the session watcher flag, roles — a VBase-backed cache would just swap one VBase read for another; those caches are memory-only. + +### Current resources + +| Resource | Origin | Layers | Memory TTL | VBase TTL | Bound | Key | +|---|---|---|---|---|---|---| +| `app-settings` | Apps API | both | 5min | 5min | 50 entries | appId | +| `sales-channel` | catalog `pvt` REST | both | 5min | 6h | 100 | `list` | +| `b2b-settings` | b2b-organizations GraphQL | both | 5min | 5min | 100 | `settings` | +| `organization` | Master Data | both | 60s | 2min | 10000 | orgId | +| `cost-center` | b2b-organizations GraphQL | both | 60s | 2min | **8MB byte budget** | costId | +| `active-user` | Master Data (paginated) | both | 5min¹ | 5min | 10000 | `email\|b2bCurrentCostCenter` | +| `active-user-permissions` | Master Data (paginated) | memory only | 60s | — | 10000 | email | +| `region` | checkout REST | both | 30min | 30min | 10000 | `country\|postalCode\|sc\|geo` | +| `session-watcher` | VBase | memory only | 60s | — | 100 | `active` | +| `roles` | VBase (MD fallback) | memory only | 5min | — | 100 | `all` | + +¹ Configurable via the `sessionUserCacheTtlMs` app setting; `0` disables. + +### Why the TTLs are what they are + +- **Sales channel list (6h):** effectively static account data. +- **App settings (5+5min):** feature flags an operator may flip; worst-case propagation is roughly memory TTL + VBase TTL (~10 minutes), because the memory layer holds its entry for its TTL and then may read a stale VBase entry once before the background refresh lands. +- **Organization / cost center (60s/2min):** deliberately short — `organization.status === 'inactive'` blocks the user (`ForbiddenError`), so deactivating an organization must take effect within minutes. +- **Session watcher (60s):** it is the operational kill switch; disabling it must bite quickly. +- **Active user:** the TTL is only a safety net. The cache key contains the session's `public.b2bCurrentCostCenter`, which `setCurrentOrganization` writes on every organization switch — so a switch changes the key and misses the cache immediately, regardless of TTL. The TTL covers changes that bypass that mutation (an admin editing a user's organizations, the inactive-org fallback). +- **`active-user-permissions` (60s, memory only):** the `checkPermissions` route receives only `app` + `email`, so there is no cost center to key on and no key-based invalidation. Short TTL bounds how long stale permissions can survive an organization switch; no VBase layer so nothing extends that window. + +### Why the cost center cache is bounded by bytes + +Measured on a real account: organization documents span **187–480 bytes** (tight), while cost center documents span **~400 bytes to 29KB** (~70x, driven by the addresses list). A fixed entry count therefore makes the cost-center cache's memory footprint swing by 70x with the data. With a byte budget, `lru-cache` treats `max` as total serialized size: one unusually large document evicts others — and a document larger than the whole budget is *refused*, never stored. Note a parsed object costs roughly 2–3x its serialized length in heap; size budgets accordingly. + +## Multi-tenancy + +A pod serves **more than one account** (the service route carries `{account}/{workspace}`), and the LRUs are module-level singletons shared by every request the pod handles. Two consequences: + +- **Memory keys must be tenant-scoped.** `createCachedResource` prefixes every key with `${account}-${workspace}` automatically. Never build a cache outside it without doing the same — a missing prefix is a cross-tenant data leak. +- **VBase keys must NOT contain the account.** The VBase client is itself scoped to account + workspace (its path is `/vbase/v2/{account}/{workspace}/...`), so adding the account would be redundant; the app already relies on this for `b2b_roles` and `b2b_settings`. + +Entry bounds are **global budgets across all tenants on the pod**, not per account. Hit rates are reported per pod every five minutes (see [Observability](OBSERVABILITY.md), `cacheStats`) — tune bounds from those numbers, not guesses. + +## Service sizing (`node/service.json`) + +`memory: 1024`, `ttl: 300`, `timeout: 60` — the same profile as `b2b-organizations-graphql` and `b2b-checkout-settings`. Two settings that look tunable but should not be changed casually: + +- **`workers: 1` is intentional.** Each worker is a separate Node process with its own LRUs; two workers would duplicate every cache (double memory) and halve the hit rate. Scale with replicas, not workers. +- **`timeout: 60` vs the 2s session budget:** Session Manager stops waiting at 2s, but this service also hosts the admin GraphQL routes (user/role listing, bulk operations) that legitimately need the headroom, so the global timeout stays at the suite standard. + +## Known measurements (Aug 2026, kohlerqa) + +| Scenario | Before | After | +|---|---|---| +| Warm pod, server-side | ~1240ms | **~50–150ms** | +| Cold pod, warm VBase (scale-up) | ~1870ms | **~950ms** | +| Cold pod, cold VBase (first pod after deploy) | ~1870ms | ~1900ms (pays origin once, then warms VBase for all pods) | + +## One platform gotcha worth knowing + +Saved app settings are scoped to the app's **major version range** (`vtex.storefront-permissions@3.x`). They persist across minor/patch releases and start **empty** on a new major — every merchant silently reverts to `settingsSchema` defaults until settings are re-applied. Plan a settings re-apply step into any major-version upgrade, and prefer fail-safe defaults (losing the stored value should degrade behavior, not change it). diff --git a/docs/README.md b/docs/README.md index 554cbdc9..d6abdb28 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,16 @@ It also allows you to configure available permissions when developing your own a For B2B session behavior related to **cost center address selection** and **region overwrite** (multiple addresses per cost center, optional region from postal code/country), see [Cost center address and region](COST_CENTER_ADDRESS_AND_REGION.md). +### Technical documentation + +| Document | Covers | +|---|---| +| [Performance and caching](PERFORMANCE_AND_CACHING.md) | How the `setProfile` session transform is structured, the two-layer cache architecture, TTLs and bounds, multi-tenancy rules, service sizing, and the rules to follow when changing the transform | +| [Observability](OBSERVABILITY.md) | Every log signal the app emits, the tunable settings, how to debug a slow or failing session transform, and suggested alerts | +| [Region resolution](REGION_RESOLUTION.md) | Who resolves the session region and from which address: the default flow, the `deferRegionToCheckoutSession` handoff, the shopper-driven region overwrite, and how they compose | +| [Cost center address and region](COST_CENTER_ADDRESS_AND_REGION.md) | Cost center address selection and the region overwrite input contract | +| [Sales channel and binding coexistence](SALES_CHANNEL_BINDING_COEXISTENCE.md) | The `deferSalesChannelToBinding` setting and how this app coexists with `vtex.binding-selector` | + For B2B session behavior related to **sales channel coexistence with binding selection** (multi-binding stores using `vtex.binding-selector`), see [Sales channel coexistence with binding selection](SALES_CHANNEL_BINDING_COEXISTENCE.md). The **Storefront Permissions** app does not contain an interface – it operates “backstage”, storing the predefined roles and serving as a bridge to communicate with other apps in order to check user permissions. If you would like to manage roles and app permissions using the VTEX Admin interface, you must also install the [Storefront Permissions UI](https://developers.vtex.com/vtex-developer-docs/docs/vtex-storefront-permissions-ui) app. As an optional feature, you can install the [Admin Customers](https://developers.vtex.com/vtex-developer-docs/docs/vtex-admin-customers) app for additional customer management capabilities. diff --git a/docs/REGION_RESOLUTION.md b/docs/REGION_RESOLUTION.md new file mode 100644 index 00000000..e35ec62d --- /dev/null +++ b/docs/REGION_RESOLUTION.md @@ -0,0 +1,43 @@ +# Region resolution: who sets the region, from which address + +Three mechanisms can influence the session's region. They answer **different questions** and are designed to compose — removing one of them is not a simplification, it changes behavior. This doc is the map. + +| Mechanism | Question it answers | Locality used | Scope | +|---|---|---|---| +| Default (no flags) | — | Cost center address | This app calls the checkout regions API and writes `public.regionId` | +| `deferRegionToCheckoutSession` (app setting) | **Who resolves** the region | Cost center address (same as default) | Account-wide | +| `enableRegionOverwrite` + `public.allowRegionOverwrite` (setting + per-request input) | **Which location** the region is for | Whatever the shopper entered | Per request, shopper-initiated | + +## Default behavior + +`setProfile` resolves the region from the **selected cost center address** (see [Cost center address and region](COST_CENTER_ADDRESS_AND_REGION.md) for how the address is selected) by calling `checkout.getRegionId(country, postalCode, sc)`, and writes the result to `public.regionId`. The lookup is cached (30min, both layers) keyed by the full input tuple, since the result is a pure function of it. + +## `deferRegionToCheckoutSession` — the server-side handoff + +When enabled, this app stops calling the regions API. Instead it publishes the cost center locality as **`public.postalCode` + `public.country`** and leaves `public.regionId` untouched (the key is *deleted* from the response, never written empty, so a region another app resolved is never cleared). Downstream: + +- **`vtex.checkout-session`** reads `public.regionId` first ("direct insert"); when absent it resolves `checkout.regionId` itself from `public.country` + `public.postalCode`/`geoCoordinates` + sales channel — the *same* lookup this app used to make, cached on their side (60min expiry / 10min revalidate). `checkout.regionId` is the canonical field the platform and `vtex_segment` consume. +- **`vtex.search-session`** reads `public.postalCode`/`country` (never the `checkout` namespace) to regionalize Intelligent Search. This fixes a long-standing inconsistency: previously the cart was regionalized by the cost center address while search saw no locality at all. +- Sales channel: checkout-session uses `public.sc`, falling back to `store.channel` — so this composes correctly with `deferSalesChannelToBinding` (see [Sales channel and binding coexistence](SALES_CHANNEL_BINDING_COEXISTENCE.md)). + +Safety valves: the handoff requires the cost center address to have a country **and** postal code (checkout-session's input contract); otherwise this app falls back to resolving the region itself. And it stands down entirely when region overwrite is active for the request. + +> Rollout note: because search starts seeing the cost center locality, QA product availability and delivery promises for a B2B user before enabling this on an account. + +## `allowRegionOverwrite` — the shopper's "check delivery to another location" + +This is a **product feature**, not an implementation detail, and it must not be removed in favor of the handoff: the two are not equivalent. With only the server-side flag, this app writes the cost center locality on every transform — a shopper-entered postal code would be overwritten right back. `allowRegionOverwrite` is the signal that tells both resolution modes to stand down for the request so the shopper's location wins: `public.regionId` is set explicitly empty, the cart shipping address is not stamped, and checkout-session resolves from the shopper's values. + +Contract for frontends (unchanged): send `allowRegionOverwrite` **together with** the shopper's `public.postalCode` and `public.country`. This matters more with the handoff enabled, because postal-code *presence* in the session no longer implies the shopper typed it — this app may have written the cost center's. + +## Session contract + +`vtex.session/configuration.json`: `postalCode` and `country` are both **inputs** (read by the overwrite detection — removing them silently kills that feature, since the session runtime would stop copying them into the transform body) and **outputs** (written by the handoff). The same dual input/output pattern the app already uses for `hash`. + +## Known consumers of `public.regionId` + +Only two apps declare it in a session contract: `vtex.checkout-session` (optional short-circuit; resolves itself when absent) and `vtex.price-table-selector` (rule variable `public.regionId.value`, **no fallback** — an account using price-table rules keyed on region should not enable the handoff without reviewing those rules). + +## History + +The address-selection and region-overwrite features shipped together (Feb 2026) for a headless B2B storefront whose frontend writes the cost center locality to `public.postalCode`/`country` client-side on cost-center change — effectively the same pattern `deferRegionToCheckoutSession` now implements server-side. The two write the same values from the same source and are compatible. diff --git a/manifest.json b/manifest.json index cc77af0f..7b2e36e3 100644 --- a/manifest.json +++ b/manifest.json @@ -157,12 +157,42 @@ "type": "boolean", "default": false }, + "deferRegionToCheckoutSession": { + "title": "Defer region to checkout-session", + "description": "When enabled, this app stops calling the checkout regions API. Instead it publishes the selected cost center address as public.postalCode and public.country and leaves public.regionId untouched, so vtex.checkout-session resolves checkout.regionId from that locality (it runs the same lookup and caches it). This removes one external call per session transform and makes vtex.search-session regionalize search with the same address the cart uses. Requires the cost center address to have a country and postal code; otherwise the app falls back to resolving the region itself. Has no effect when region overwrite is active for the request.", + "type": "boolean", + "default": false + }, "deferSalesChannelToBinding": { "title": "Defer sales channel to binding", "description": "When enabled, if the organization has no salesChannel set, this app does not patch the session's public.sc or restamp the cart's sales channel, leaving that value to whatever already set it (e.g. vtex.binding-selector). When disabled, an organization with no salesChannel set falls back to the account's first active sales channel (backward compatible).", "type": "boolean", "default": false }, + "logSessionPayloads": { + "title": "Log full session payloads", + "description": "When enabled, every session transform logs the complete request body and response. Useful for debugging a specific account, but expensive on a route this frequently called, and the payloads include the shopper's email and organization data. Keep disabled outside of active investigation. Defaults to false.", + "type": "boolean", + "default": false + }, + "sessionUserCacheTtlMs": { + "title": "Active user cache TTL (ms)", + "description": "How long the resolved active user (organization and cost center for an email) is cached. The session transform runs several times per storefront navigation, so this removes most of the repeated Master Data lookups. The cache key already includes the session's b2bCurrentCostCenter, so switching organization invalidates it immediately; this TTL only bounds changes made outside that flow, such as an admin editing the user's organizations. Set to 0 to disable. Defaults to 300000ms (5 minutes).", + "type": "number", + "default": 300000 + }, + "sessionTimingsSlowThresholdMs": { + "title": "Session timings slow threshold (ms)", + "description": "When a session transform (setProfile) takes at least this many milliseconds, a 'setProfile.timings' warning is logged with the duration of every external call, so the bottleneck can be identified in production. Defaults to 1000ms.", + "type": "number", + "default": 1000 + }, + "sessionTimingsSampleRate": { + "title": "Session timings sample rate", + "description": "Fraction (0 to 1) of non-slow session transforms that also log their timings, to establish a baseline. Keep low on high-traffic accounts: 0.01 logs 1% of requests. Defaults to 0 (only slow requests are logged).", + "type": "number", + "default": 0 + }, "strictImpersonationPermissions": { "title": "Strict impersonation permissions", "description": "When enabled, checkUserPermission returns only the impersonated profile's permissions during an impersonation session, so the acting user's (Operator, sales representative, approver) permissions never leak into the storefront. When disabled (default), the permissions of the acting user and the impersonated profile are aggregated, which is required by flows that rely on the acting user's rights while impersonating - for example a sales representative completing checkout for a buyer role that has no can-checkout permission, or an approver retaining approval power.", diff --git a/node/.vtexignore b/node/.vtexignore index 320a1125..6b45da0f 100644 --- a/node/.vtexignore +++ b/node/.vtexignore @@ -3,3 +3,5 @@ node_modules/ .eslintrc .gitignore README.md +__tests__/ +jest.config.js diff --git a/node/__tests__/cache.test.ts b/node/__tests__/cache.test.ts new file mode 100644 index 00000000..4225c546 --- /dev/null +++ b/node/__tests__/cache.test.ts @@ -0,0 +1,123 @@ +import { collectCacheStats, createCachedResource } from '../services/cache' + +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +let uniq = 0 + +const makeCtx = (account: string, vbaseStored: unknown = null) => + ({ + clients: { + vbase: { + getJSON: jest.fn().mockResolvedValue(vbaseStored), + saveJSON: jest.fn().mockResolvedValue(undefined), + }, + }, + vtex: { account, workspace: 'master' }, + } as any) + +// Each test gets its own resource: caches are module-level singletons keyed by +// name, so reusing names across tests would leak state. +const makeResource = (options: any) => + createCachedResource(`test-${uniq++}`, options) + +describe('createCachedResource', () => { + it('serves repeated reads from memory without refetching', async () => { + const cached = makeResource({ memoryTtlMs: 60000 }) + const ctx = makeCtx('acc1') + const fetcher = jest.fn().mockResolvedValue({ v: 1 }) + + expect(await cached(ctx, 'k', fetcher)).toEqual({ v: 1 }) + expect(await cached(ctx, 'k', fetcher)).toEqual({ v: 1 }) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('isolates tenants: same key, different account, different entry', async () => { + const cached = makeResource({ memoryTtlMs: 60000 }) + const fetcherA = jest.fn().mockResolvedValue('for-a') + const fetcherB = jest.fn().mockResolvedValue('for-b') + + expect(await cached(makeCtx('account-a'), 'k', fetcherA)).toBe('for-a') + expect(await cached(makeCtx('account-b'), 'k', fetcherB)).toBe('for-b') + expect(fetcherA).toHaveBeenCalledTimes(1) + expect(fetcherB).toHaveBeenCalledTimes(1) + }) + + it('bypasses caching entirely when the TTL is zero', async () => { + const cached = makeResource({ memoryTtlMs: 0 }) + const ctx = makeCtx('acc2') + const fetcher = jest.fn().mockResolvedValue('fresh') + + await cached(ctx, 'k', fetcher) + await cached(ctx, 'k', fetcher) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('honours a per-call TTL override', async () => { + const cached = makeResource({ memoryTtlMs: 60000 }) + const ctx = makeCtx('acc3') + const fetcher = jest.fn().mockResolvedValue('x') + + await cached(ctx, 'k', fetcher, { memoryTtlMs: 0 }) + await cached(ctx, 'k', fetcher, { memoryTtlMs: 0 }) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('bounds by bytes: an oversized value is not retained', async () => { + const cached = makeResource({ maxSizeBytes: 1024, memoryTtlMs: 60000 }) + const ctx = makeCtx('acc4') + const big = { pad: 'x'.repeat(5000) } + const fetcher = jest.fn().mockResolvedValue(big) + + expect(await cached(ctx, 'k', fetcher)).toEqual(big) + // Larger than the whole budget, so it was refused rather than stored. + await cached(ctx, 'k', fetcher) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('bounds by bytes: small values within the budget are retained', async () => { + const cached = makeResource({ maxSizeBytes: 1024, memoryTtlMs: 60000 }) + const ctx = makeCtx('acc5') + const fetcher = jest.fn().mockResolvedValue({ small: true }) + + await cached(ctx, 'k', fetcher) + await cached(ctx, 'k', fetcher) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('reads through VBase when configured, without calling the origin', async () => { + const cached = makeResource({ memoryTtlMs: 60000, vbaseTtlMinutes: 5 }) + const future = new Date(Date.now() + 60000) + const ctx = makeCtx('acc6', { data: { from: 'vbase' }, ttl: future }) + const fetcher = jest.fn() + + expect(await cached(ctx, 'k', fetcher)).toEqual({ from: 'vbase' }) + expect(fetcher).not.toHaveBeenCalled() + expect(ctx.clients.vbase.getJSON).toHaveBeenCalledTimes(1) + }) + + it('populates VBase on a full miss', async () => { + const cached = makeResource({ memoryTtlMs: 60000, vbaseTtlMinutes: 5 }) + const ctx = makeCtx('acc7', null) + const fetcher = jest.fn().mockResolvedValue({ fresh: true }) + + expect(await cached(ctx, 'k', fetcher)).toEqual({ fresh: true }) + expect(fetcher).toHaveBeenCalledTimes(1) + + await flush() + expect(ctx.clients.vbase.saveJSON).toHaveBeenCalledTimes(1) + }) + + it('registers itself for stats collection', async () => { + const cached = makeResource({ memoryTtlMs: 60000 }) + const ctx = makeCtx('acc8') + + await cached(ctx, 'k', jest.fn().mockResolvedValue(1)) + await cached(ctx, 'k', jest.fn().mockResolvedValue(1)) + + const stats = collectCacheStats() + const mine = stats.find((s: any) => s.name === `test-${uniq - 1}`) + + expect(mine).toBeDefined() + expect((mine as any).itemCount).toBe(1) + }) +}) diff --git a/node/__tests__/checkPermissions.test.ts b/node/__tests__/checkPermissions.test.ts new file mode 100644 index 00000000..f5fc3d89 --- /dev/null +++ b/node/__tests__/checkPermissions.test.ts @@ -0,0 +1,132 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { json } from 'co-body' + +import { Routes } from '../resolvers/Routes' + +jest.mock('co-body', () => ({ json: jest.fn() })) + +process.env.VTEX_APP_ID = 'vtex.storefront-permissions@3.6.1' + +const jsonMock = json as jest.Mock + +let uniq = 0 + +const role = { + features: [{ features: ['perm1', 'perm2'], module: 'test-app' }], + id: 'role1', + locked: false, + name: 'Admin', + slug: 'admin', +} + +const userDoc = { + active: true, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + roleId: 'role1', +} + +const makeCtx = () => + ({ + clients: { + masterdata: { + searchDocumentsWithPaginationInfo: jest.fn().mockResolvedValue({ + data: [userDoc], + pagination: { page: 1, total: 1 }, + }), + }, + vbase: { + getJSON: jest.fn().mockImplementation((bucket: string) => { + if (bucket === 'b2b_roles') { + return Promise.resolve([role]) + } + + return Promise.resolve(null) + }), + saveJSON: jest.fn().mockResolvedValue(undefined), + }, + }, + req: {}, + response: {}, + set: jest.fn(), + vtex: { + // Module-level caches are account-scoped, so a unique account per ctx + // keeps tests isolated from each other. + account: `permacc${uniq++}`, + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + workspace: 'master', + }, + } as any) + +const run = async (ctx: any, params: any = {}) => { + jsonMock.mockResolvedValue({ + app: 'test-app', + email: 'buyer@test.com', + ...params, + }) + + await Routes.checkPermissions(ctx) + + return ctx.response.body +} + +describe('checkPermissions', () => { + it('resolves the role and permissions for the requested app', async () => { + const ctx = makeCtx() + const response = await run(ctx) + + expect(ctx.response.status).toBe(200) + expect(response.role.id).toBe('role1') + expect(response.permissions).toEqual(['perm1', 'perm2']) + }) + + it('returns empty permissions when the app has no module in the role', async () => { + const ctx = makeCtx() + const response = await run(ctx, { app: 'unknown-app' }) + + expect(response.permissions).toEqual([]) + expect(response.role.id).toBe('role1') + }) + + it('serves repeated checks for the same email from the permissions cache', async () => { + const ctx = makeCtx() + const lookups = ctx.clients.masterdata.searchDocumentsWithPaginationInfo + + await run(ctx) + // One resolution = two Master Data calls (count probe + one page). + expect(lookups).toHaveBeenCalledTimes(2) + + await run(ctx) + // This route is called per request by sibling B2B apps, so the second + // check must be a cache hit. + expect(lookups).toHaveBeenCalledTimes(2) + }) + + it('does not share cached users between accounts', async () => { + const first = makeCtx() + const second = makeCtx() + + await run(first) + await run(second) + + expect( + first.clients.masterdata.searchDocumentsWithPaginationInfo + ).toHaveBeenCalledTimes(2) + expect( + second.clients.masterdata.searchDocumentsWithPaginationInfo + ).toHaveBeenCalledTimes(2) + }) + + it('rejects requests without an app or an email', async () => { + await expect(run(makeCtx(), { app: null })).rejects.toThrow( + 'App not defined' + ) + await expect(run(makeCtx(), { email: null })).rejects.toThrow( + 'Email not defined' + ) + }) +}) diff --git a/node/__tests__/requestTimings.test.ts b/node/__tests__/requestTimings.test.ts new file mode 100644 index 00000000..e57f5f46 --- /dev/null +++ b/node/__tests__/requestTimings.test.ts @@ -0,0 +1,106 @@ +import { + attachTimer, + createTimer, + getTimer, + logRequestTimings, +} from '../utils/requestTimings' + +const makeLogger = () => + ({ error: jest.fn(), info: jest.fn(), warn: jest.fn() } as any) + +describe('createTimer', () => { + it('records the duration of tracked promises, including failed ones', async () => { + const timer = createTimer() + + await timer.track('ok', Promise.resolve('x')) + await expect( + timer.track('boom', Promise.reject(new Error('nope'))) + ).rejects.toThrow('nope') + + expect(timer.timings.ok).toBeGreaterThanOrEqual(0) + expect(timer.timings.boom).toBeGreaterThanOrEqual(0) + expect(timer.totalMs()).toBeGreaterThanOrEqual(0) + }) + + it('is retrievable through the request-context WeakMap', () => { + const ctx = {} + const timer = createTimer() + + attachTimer(ctx, timer) + expect(getTimer(ctx)).toBe(timer) + expect(getTimer({})).toBeUndefined() + }) +}) + +describe('logRequestTimings', () => { + it('logs a warn when the request is slow', async () => { + const timer = createTimer() + + await timer.track('slowStep', Promise.resolve(1)) + timer.timings.slowStep = 800 + timer.timings.fastStep = 5 + + const logger = makeLogger() + + logRequestTimings({ + logger, + message: 'test.timings', + slowThresholdMs: 0, + timer, + }) + + expect(logger.warn).toHaveBeenCalledTimes(1) + const payload = logger.warn.mock.calls[0][0] + + expect(payload.message).toBe('test.timings') + expect(payload.slowestStep).toBe('slowStep') + expect(payload.slowestStepMs).toBe(800) + expect(payload.timings).toEqual({ fastStep: 5, slowStep: 800 }) + }) + + it('stays silent for fast requests when not sampled', () => { + const logger = makeLogger() + + logRequestTimings({ + logger, + message: 'test.timings', + slowThresholdMs: 60000, + timer: createTimer(), + }) + + expect(logger.warn).not.toHaveBeenCalled() + expect(logger.info).not.toHaveBeenCalled() + }) + + it('logs an info when sampled, even if fast', () => { + const logger = makeLogger() + + logRequestTimings({ + logger, + message: 'test.timings', + sampleRate: 1, + slowThresholdMs: 60000, + timer: createTimer(), + }) + + expect(logger.info).toHaveBeenCalledTimes(1) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('spreads extra context into the payload', () => { + const logger = makeLogger() + + logRequestTimings({ + extra: { failed: true, orgId: 'org1' }, + logger, + message: 'test.timings', + slowThresholdMs: 0, + timer: createTimer(), + }) + + const payload = logger.warn.mock.calls[0][0] + + expect(payload.failed).toBe(true) + expect(payload.orgId).toBe('org1') + }) +}) diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts new file mode 100644 index 00000000..caf00e7c --- /dev/null +++ b/node/__tests__/setProfile.test.ts @@ -0,0 +1,339 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { json } from 'co-body' + +import { Routes } from '../resolvers/Routes' +import { getUserOrganizationsData } from '../resolvers/Routes/utils' + +jest.mock('co-body', () => ({ json: jest.fn() })) + +jest.mock('../resolvers/Routes/utils', () => ({ + ...jest.requireActual('../resolvers/Routes/utils'), + generateClUser: jest.fn().mockResolvedValue(null), + getUserOrganizationsData: jest.fn(), +})) + +jest.mock('../resolvers/Mutations/Users', () => ({ + getUser: jest.fn(), + setActiveUserByOrganization: jest.fn().mockResolvedValue(undefined), +})) + +process.env.VTEX_APP_ID = 'vtex.storefront-permissions@3.6.1' + +const jsonMock = json as jest.Mock + +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +let uniq = 0 + +interface Scenario { + appSettings?: Record + costCenterAddresses?: any[] + organization?: Record + recoveredOrganization?: Record + sessionWatcherActive?: boolean +} + +const defaultAddress = { + addressId: 'addr1', + country: 'USA', + geoCoordinates: null, + postalCode: '53012', +} + +const makeCtx = (scenario: Scenario = {}) => { + const { + appSettings = {}, + costCenterAddresses = [defaultAddress], + organization = { + collections: null, + name: 'Test Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + recoveredOrganization, + sessionWatcherActive = true, + } = scenario + + const userDoc = { + active: true, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + } + + const ctx: any = { + clients: { + apps: { getAppSettings: jest.fn().mockResolvedValue(appSettings) }, + checkout: { + clearCart: jest.fn().mockResolvedValue({}), + getRegionId: jest.fn().mockResolvedValue([{ id: 'v2.TESTREGION' }]), + updateOrderFormMarketingData: jest.fn().mockResolvedValue({}), + updateOrderFormProfile: jest.fn().mockResolvedValue({}), + updateOrderFormShipping: jest.fn().mockResolvedValue({}), + updateSalesChannel: jest.fn().mockResolvedValue({}), + }, + masterDataExtended: { + getDocumentById: jest.fn().mockImplementation((entity, id) => { + if ( + entity === 'organizations' && + recoveredOrganization && + id !== 'org1' + ) { + return Promise.resolve(recoveredOrganization) + } + + return Promise.resolve(organization) + }), + }, + masterdata: { + searchDocumentsWithPaginationInfo: jest.fn().mockResolvedValue({ + data: [userDoc], + pagination: { page: 1, total: 1 }, + }), + }, + organizations: { + getB2BSettings: jest.fn().mockResolvedValue({ + data: { getB2BSettings: { uiSettings: { clearCart: false } } }, + }), + getCostCenterById: jest.fn().mockResolvedValue({ + data: { + getCostCenterById: { + addresses: costCenterAddresses, + businessDocument: null, + phoneNumber: null, + sellers: null, + stateRegistration: null, + }, + }, + }), + getMarketingTags: jest + .fn() + .mockResolvedValue({ data: { getMarketingTags: { tags: [] } } }), + getOrganizationsByEmail: jest.fn(), + }, + profileSystem: {}, + salesChannel: { + getSalesChannel: jest + .fn() + .mockResolvedValue({ data: [{ Id: 1, IsActive: true }] }), + }, + vbase: { + getJSON: jest.fn().mockImplementation((bucket: string) => { + if (bucket === 'b2b_settings') { + return Promise.resolve({ + sessionWatcher: { active: sessionWatcherActive }, + }) + } + + // sfp-cache misses so every fetcher actually runs in tests. + return Promise.resolve(null) + }), + saveJSON: jest.fn().mockResolvedValue(undefined), + }, + }, + req: {}, + response: {}, + set: jest.fn(), + vtex: { + // Module-level caches are account-scoped, so a unique account per ctx + // keeps tests isolated from each other. + account: `testacc${uniq++}`, + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + tenant: { locale: 'en-US' }, + workspace: 'master', + }, + } + + return ctx +} + +const makeBody = () => ({ + authentication: { storeUserEmail: { value: 'buyer@test.com' } }, + checkout: { orderFormId: { value: 'of123' } }, + public: {}, + 'storefront-permissions': { hash: { value: '' } }, +}) + +const run = async (ctx: any, body: any = makeBody()) => { + jsonMock.mockResolvedValue(body) + await Routes.setProfile(ctx) + await flush() + + return ctx.response.body +} + +describe('setProfile', () => { + it('returns the empty response and calls nothing when the watcher is off', async () => { + const ctx = makeCtx({ sessionWatcherActive: false }) + const response = await run(ctx) + + expect(ctx.response.status).toBe(200) + expect(response['storefront-permissions'].organization.value).toBe('') + expect( + ctx.clients.masterdata.searchDocumentsWithPaginationInfo + ).not.toHaveBeenCalled() + }) + + it('falls back to the first active sales channel for a null-channel org', async () => { + const ctx = makeCtx() + const response = await run(ctx) + + expect(response.public.sc.value).toBe('1') + expect(ctx.clients.checkout.updateSalesChannel).toHaveBeenCalledWith( + 'of123', + 1 + ) + }) + + it('omits sc entirely when deferSalesChannelToBinding is on', async () => { + const ctx = makeCtx({ appSettings: { deferSalesChannelToBinding: true } }) + const response = await run(ctx) + + expect(response.public.sc).toBeUndefined() + expect(ctx.clients.checkout.updateSalesChannel).not.toHaveBeenCalled() + // The region lookup still has a sales channel to work with. + expect(response.public.regionId.value).toBe('v2.TESTREGION') + }) + + it('resolves the region from the cost center address by default', async () => { + const ctx = makeCtx() + const response = await run(ctx) + + expect(response.public.regionId.value).toBe('v2.TESTREGION') + expect(ctx.clients.checkout.getRegionId).toHaveBeenCalledWith( + 'USA', + '53012', + '1', + null + ) + expect(response.public.postalCode).toBeUndefined() + }) + + it('publishes the locality instead of calling the regions API when deferRegionToCheckoutSession is on', async () => { + const ctx = makeCtx({ + appSettings: { deferRegionToCheckoutSession: true }, + }) + + const response = await run(ctx) + + expect(ctx.clients.checkout.getRegionId).not.toHaveBeenCalled() + expect(response.public.regionId).toBeUndefined() + expect(response.public.postalCode.value).toBe('53012') + expect(response.public.country.value).toBe('USA') + }) + + it('falls back to the regions API when the address has no postal code, even with the flag on', async () => { + const ctx = makeCtx({ + appSettings: { deferRegionToCheckoutSession: true }, + costCenterAddresses: [{ ...defaultAddress, postalCode: null }], + }) + + const response = await run(ctx) + + expect(ctx.clients.checkout.getRegionId).toHaveBeenCalled() + expect(response.public.postalCode).toBeUndefined() + expect(response.public.regionId.value).toBe('v2.TESTREGION') + }) + + it('recovers a user whose organization is inactive but has another active one', async () => { + const orgsDataMock = getUserOrganizationsData as jest.Mock + + orgsDataMock.mockResolvedValue({ + activeOrganization: { costId: 'cost2', id: 'u2', orgId: 'org2' }, + validCostCenterId: null, + }) + + const ctx = makeCtx({ + organization: { + collections: null, + name: 'Inactive Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'inactive', + tradeName: null, + }, + recoveredOrganization: { + collections: null, + name: 'Recovered Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + }) + + // With the old `.data.getOrganizationById` unwrap this threw a TypeError + // and returned a 500; the fix must complete normally. + await run(ctx) + + expect(ctx.response.status).toBe(200) + expect(getUserOrganizationsData).toHaveBeenCalled() + }) + + it('keeps full payload logging off unless logSessionPayloads is enabled', async () => { + const quiet = makeCtx() + + await run(quiet) + + const quietPayloads = quiet.vtex.logger.info.mock.calls.filter( + (call: any[]) => call[0] && call[0]['setProfile.body'] + ) + + expect(quietPayloads).toHaveLength(0) + + const verbose = makeCtx({ appSettings: { logSessionPayloads: true } }) + + await run(verbose) + + const verbosePayloads = verbose.vtex.logger.info.mock.calls.filter( + (call: any[]) => call[0] && call[0]['setProfile.body'] + ) + + expect(verbosePayloads).toHaveLength(1) + }) + + it('reuses the active-user lookup across runs and refetches when the cost center changes', async () => { + // One ctx for all runs: the caches are account-scoped module singletons, + // and this test is about sharing them across requests. + const ctx = makeCtx() + const lookups = ctx.clients.masterdata.searchDocumentsWithPaginationInfo + + await run(ctx) + // One resolution = two Master Data calls (count probe + one page). + expect(lookups).toHaveBeenCalledTimes(2) + + await run(ctx) + // Same email, same cost center: served from cache, no new lookup. + expect(lookups).toHaveBeenCalledTimes(2) + + await run(ctx, { + ...makeBody(), + public: { b2bCurrentCostCenter: { value: 'cost2' } }, + }) + // setCurrentOrganization writes b2bCurrentCostCenter on an organization + // switch; a different value must change the key and force a fresh lookup. + expect(lookups).toHaveBeenCalledTimes(4) + }) + + it('does not block the response on the CL profile update', async () => { + const ctx = makeCtx() + + // Even if the cart profile update hangs forever, the response returns. + ctx.clients.checkout.updateOrderFormProfile.mockReturnValue( + new Promise(() => undefined) + ) + + const response = await run(ctx) + + expect(response.public.facets).toBeDefined() + expect(ctx.response.status).toBe(200) + }) +}) diff --git a/node/__tests__/staleFromVBaseWhileRevalidate.test.ts b/node/__tests__/staleFromVBaseWhileRevalidate.test.ts new file mode 100644 index 00000000..7186f305 --- /dev/null +++ b/node/__tests__/staleFromVBaseWhileRevalidate.test.ts @@ -0,0 +1,169 @@ +import { staleFromVBaseWhileRevalidate } from '../utils/staleFromVBaseWhileRevalidate' + +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +const makeVBase = (stored: unknown) => + ({ + getJSON: jest.fn().mockResolvedValue(stored), + saveJSON: jest.fn().mockResolvedValue(undefined), + } as any) + +describe('staleFromVBaseWhileRevalidate', () => { + it('fetches, stores and returns when there is no cached entry', async () => { + const vbase = makeVBase(null) + const fetcher = jest.fn().mockResolvedValue({ some: 'data' }) + + const result = await staleFromVBaseWhileRevalidate( + vbase, + 'bucket', + 'key', + fetcher + ) + + expect(result).toEqual({ some: 'data' }) + expect(fetcher).toHaveBeenCalledTimes(1) + + await flush() + expect(vbase.saveJSON).toHaveBeenCalledTimes(1) + + const [, , saved] = vbase.saveJSON.mock.calls[0] + + expect(saved.data).toEqual({ some: 'data' }) + expect(new Date(saved.ttl).getTime()).toBeGreaterThan(Date.now()) + }) + + it('returns the cached value without fetching while fresh', async () => { + const future = new Date(Date.now() + 60 * 1000) + const vbase = makeVBase({ data: { cached: true }, ttl: future }) + const fetcher = jest.fn() + + const result = await staleFromVBaseWhileRevalidate( + vbase, + 'bucket', + 'key', + fetcher + ) + + expect(result).toEqual({ cached: true }) + expect(fetcher).not.toHaveBeenCalled() + expect(vbase.saveJSON).not.toHaveBeenCalled() + }) + + it('serves stale immediately and revalidates in the background', async () => { + const past = new Date(Date.now() - 60 * 1000) + const vbase = makeVBase({ data: { cached: 'stale' }, ttl: past }) + const fetcher = jest.fn().mockResolvedValue({ cached: 'fresh' }) + + const result = await staleFromVBaseWhileRevalidate( + vbase, + 'bucket', + 'key', + fetcher + ) + + // The caller gets the stale value with no waiting. + expect(result).toEqual({ cached: 'stale' }) + + await flush() + + // ...while the fresh value is fetched and stored for the next caller. + expect(fetcher).toHaveBeenCalledTimes(1) + expect(vbase.saveJSON).toHaveBeenCalledTimes(1) + expect(vbase.saveJSON.mock.calls[0][2].data).toEqual({ cached: 'fresh' }) + }) + + it('falls back to the fetcher when the VBase read fails', async () => { + const vbase = { + getJSON: jest.fn().mockRejectedValue(new Error('vbase down')), + saveJSON: jest.fn().mockResolvedValue(undefined), + } as any + + const fetcher = jest.fn().mockResolvedValue({ origin: true }) + + const result = await staleFromVBaseWhileRevalidate( + vbase, + 'bucket', + 'key', + fetcher + ) + + expect(result).toEqual({ origin: true }) + }) + + it('does not fail the caller when the background save fails, but logs it', async () => { + const vbase = { + getJSON: jest.fn().mockResolvedValue(null), + saveJSON: jest.fn().mockRejectedValue(new Error('write denied')), + } as any + + const logger = { error: jest.fn(), warn: jest.fn() } as any + + const result = await staleFromVBaseWhileRevalidate( + vbase, + 'bucket', + 'key', + jest.fn().mockResolvedValue('value'), + undefined, + { logger } + ) + + expect(result).toBe('value') + await flush() + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error.mock.calls[0][0].message).toBe( + 'staleFromVBase.saveError' + ) + }) + + it('logs a failing background revalidation while still serving stale', async () => { + const past = new Date(Date.now() - 60 * 1000) + const vbase = makeVBase({ data: { cached: 'stale' }, ttl: past }) + const logger = { error: jest.fn(), warn: jest.fn() } as any + const fetcher = jest.fn().mockRejectedValue(new Error('origin down')) + + const result = await staleFromVBaseWhileRevalidate( + vbase, + 'bucket', + 'my-logical-key', + fetcher, + undefined, + { logger } + ) + + // The caller is protected by the stale value, which is exactly why the + // failure has to be logged: nothing else would ever surface it. + expect(result).toEqual({ cached: 'stale' }) + + await flush() + expect(logger.error).toHaveBeenCalledTimes(1) + + const payload = logger.error.mock.calls[0][0] + + expect(payload.message).toBe('staleFromVBase.revalidateError') + expect(payload.key).toBe('my-logical-key') + }) + + it('logs a warning when the VBase read fails and the origin is used', async () => { + const vbase = { + getJSON: jest.fn().mockRejectedValue(new Error('vbase down')), + saveJSON: jest.fn().mockResolvedValue(undefined), + } as any + + const logger = { error: jest.fn(), warn: jest.fn() } as any + + const result = await staleFromVBaseWhileRevalidate( + vbase, + 'bucket', + 'key', + jest.fn().mockResolvedValue({ origin: true }), + undefined, + { logger } + ) + + expect(result).toEqual({ origin: true }) + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.warn.mock.calls[0][0].message).toBe( + 'staleFromVBase.readError' + ) + }) +}) diff --git a/node/__tests__/stubs/diagnostics.js b/node/__tests__/stubs/diagnostics.js new file mode 100644 index 00000000..52b76e1c --- /dev/null +++ b/node/__tests__/stubs/diagnostics.js @@ -0,0 +1,19 @@ +/** + * Test stub for @vtex/diagnostics-nodejs. + * + * The real package is loaded transitively by @vtex/api's logger and addresses + * its own dependencies through package `exports` subpaths, which jest 26's + * resolver (pinned by TypeScript 3.9 -> ts-jest 26) cannot resolve. Tests never + * exercise the platform log exporter, so the whole package is replaced by + * no-ops with the shape @vtex/api touches. + */ +const noop = () => undefined + +module.exports = { + Exporters: { + CreateExporter: noop, + CreateLogsExporterConfig: noop, + CreateMetricsExporterConfig: noop, + CreateTracesExporterConfig: noop, + }, +} diff --git a/node/__tests__/withRequestTimings.test.ts b/node/__tests__/withRequestTimings.test.ts new file mode 100644 index 00000000..0d72e414 --- /dev/null +++ b/node/__tests__/withRequestTimings.test.ts @@ -0,0 +1,72 @@ +import { withRequestTimings } from '../middlewares/withRequestTimings' +import { getTimer } from '../utils/requestTimings' + +const makeCtx = () => + ({ + vtex: { + account: 'acc', + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + workspace: 'master', + }, + } as any) + +describe('withRequestTimings', () => { + it('attaches a timer the handler can retrieve', async () => { + const ctx = makeCtx() + let seen: unknown + + await withRequestTimings('t')(ctx, async () => { + seen = getTimer(ctx) + }) + + expect(seen).toBeDefined() + }) + + it('stays silent on fast successful requests', async () => { + const ctx = makeCtx() + + await withRequestTimings('t')(ctx, async () => undefined) + + expect(ctx.vtex.logger.warn).not.toHaveBeenCalled() + }) + + it('logs on success when the handler lowers the threshold', async () => { + const ctx = makeCtx() + + await withRequestTimings('t')(ctx, async () => { + const timer = getTimer(ctx) + + if (timer) { + timer.meta.slowThresholdMs = 0 + timer.meta.extra = { orgId: 'org1' } + } + }) + + expect(ctx.vtex.logger.warn).toHaveBeenCalledTimes(1) + expect(ctx.vtex.logger.warn.mock.calls[0][0].orgId).toBe('org1') + }) + + it('always logs a failure and rethrows, regardless of threshold', async () => { + const ctx = makeCtx() + + await expect( + withRequestTimings('t')(ctx, async () => { + const timer = getTimer(ctx) + + if (timer) { + // Even an account configured to stay quiet must report failures. + timer.meta.slowThresholdMs = 60000 + timer.meta.extra = { orgId: 'org1' } + } + + throw new Error('handler exploded') + }) + ).rejects.toThrow('handler exploded') + + expect(ctx.vtex.logger.warn).toHaveBeenCalledTimes(1) + const payload = ctx.vtex.logger.warn.mock.calls[0][0] + + expect(payload.failed).toBe(true) + expect(payload.orgId).toBe('org1') + }) +}) diff --git a/node/index.ts b/node/index.ts index c8fd5879..89c1b26f 100644 --- a/node/index.ts +++ b/node/index.ts @@ -12,6 +12,7 @@ import { method, Service, AuthType, LRUCache } from '@vtex/api' import { schemaDirectives } from './directives' import { Clients } from './clients' +import { withRequestTimings } from './middlewares/withRequestTimings' import { resolvers } from './resolvers' const TIMEOUT_MS = 5000 @@ -79,7 +80,10 @@ export default new Service({ GET: resolvers.Routes.checkPermissions, }), setProfile: method({ - POST: resolvers.Routes.setProfile, + POST: [ + withRequestTimings('setProfile.timings'), + resolvers.Routes.setProfile, + ], }), }, }) diff --git a/node/jest.config.js b/node/jest.config.js new file mode 100644 index 00000000..0bd43aae --- /dev/null +++ b/node/jest.config.js @@ -0,0 +1,21 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], + moduleNameMapper: { + // @vtex/api transitively loads the diagnostics/OTLP stack, which relies on + // package `exports` subpaths that jest 26's resolver (pinned by + // TypeScript 3.9 -> ts-jest 26) predates. Tests never exercise the log + // exporter, so the whole package is stubbed. + '^@vtex/diagnostics-nodejs(/.*)?$': '/__tests__/stubs/diagnostics.js', + }, + // Keep the stub itself from being collected as a test file. + testPathIgnorePatterns: ['/node_modules/', '/__tests__/stubs/'], + globals: { + 'ts-jest': { + // Transpile-only keeps the suite fast; type safety is enforced by the + // builder and by `tsc --noEmit` in the lint script. + isolatedModules: true, + }, + }, +} diff --git a/node/middlewares/withRequestTimings.ts b/node/middlewares/withRequestTimings.ts new file mode 100644 index 00000000..10d657bb --- /dev/null +++ b/node/middlewares/withRequestTimings.ts @@ -0,0 +1,77 @@ +import { collectCacheStats } from '../services/cache' +import type { Timer } from '../utils/requestTimings' +import { + attachTimer, + createTimer, + logRequestTimings, +} from '../utils/requestTimings' + +const CACHE_STATS_INTERVAL_MS = 5 * 60 * 1000 + +// Start a full interval after boot, so freshly started pods do not emit an +// empty report on their first request. +let cacheStatsLastEmittedAt = Date.now() + +/** + * Piggybacks on the hot route to report per-pod cache hit rates and sizes: one + * `info` line per pod every five minutes, since apps here have no scheduler of + * their own. This is what turns the LRU bounds from guesses into numbers. + */ +const maybeEmitCacheStats = (ctx: Context) => { + const now = Date.now() + + if (now - cacheStatsLastEmittedAt < CACHE_STATS_INTERVAL_MS) { + return + } + + cacheStatsLastEmittedAt = now + + ctx.vtex.logger.info({ + message: 'cacheStats', + stats: collectCacheStats(), + }) +} + +/** + * Owns the timing telemetry for a route. + * + * The handler cannot emit this itself on the failure path, because an exception + * means it never reaches its final statement, and a failed request is exactly + * when the per-step breakdown is most useful. So the timer is created here, + * handed to the handler, and emitted from here for both outcomes: + * + * - success: only when slow or sampled, using the account's configured limits + * - failure: always, regardless of those limits + */ +export const withRequestTimings = (message: string) => + // Named rather than an anonymous arrow: service-node reports per-handler + // metrics by function name and logs an error for unnamed handlers. + async function requestTimings(ctx: Context, next: () => Promise) { + const timer: Timer = createTimer() + + attachTimer(ctx, timer) + maybeEmitCacheStats(ctx) + + try { + await next() + } catch (error) { + logRequestTimings({ + extra: { ...timer.meta.extra, failed: true }, + logger: ctx.vtex.logger, + message, + slowThresholdMs: 0, + timer, + }) + + throw error + } + + logRequestTimings({ + extra: timer.meta.extra, + logger: ctx.vtex.logger, + message, + sampleRate: timer.meta.sampleRate, + slowThresholdMs: timer.meta.slowThresholdMs, + timer, + }) + } diff --git a/node/package.json b/node/package.json index a5625e02..1218b619 100644 --- a/node/package.json +++ b/node/package.json @@ -2,7 +2,7 @@ "name": "vtex.checkout-ui-custom", "version": "3.6.1", "dependencies": { - "@vtex/api": "6.50.1", + "@vtex/api": "6.51.3", "atob": "^2.1.2", "co-body": "^6.0.0", "cookie": "^0.3.1", @@ -18,11 +18,14 @@ "@types/co-body": "0.0.3", "@types/cookie": "^0.3.2", "@types/graphql": "^14.5.0", + "@types/jest": "^26.0.24", "@types/jsonwebtoken": "^8.5.0", "@types/node": "^12.0.0", "@types/ramda": "types/npm-ramda#dist", - "@vtex/api": "6.50.1", + "@vtex/api": "6.51.3", "@vtex/prettier-config": "^0.3.1", + "jest": "^26.6.3", + "ts-jest": "^26.5.6", "tslint": "^5.12.0", "tslint-config-prettier": "^1.18.0", "tslint-config-vtex": "^2.1.0", @@ -34,6 +37,10 @@ "vtex.styleguide": "http://vtex.vtexassets.com/_v/public/typings/v1/vtex.styleguide@9.146.3/public/@types/vtex.styleguide" }, "scripts": { - "lint": "tsc --noEmit && tslint -c tslint.json './**/*.ts'" + "lint": "tsc --noEmit && tslint -c tslint.json './**/*.ts'", + "test": "jest" + }, + "resolutions": { + "node-releases": "2.0.14" } } diff --git a/node/resolvers/Queries/Roles.ts b/node/resolvers/Queries/Roles.ts index 88da4882..48a43370 100644 --- a/node/resolvers/Queries/Roles.ts +++ b/node/resolvers/Queries/Roles.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { getCachedRoles } from '../../services/rolesCache' import { currentRoleNames, currentSchema } from '../../utils' import { ROLES_VBASE_ID } from '../../utils/constants' import { getUserByRole } from './Users' @@ -68,9 +69,13 @@ export const getRole = async (_: any, params: any, ctx: Context) => { try { const { id, slug } = params + // Cached: this is the hot path for permission checks. The role mutations and + // listRoles keep reading through, so admin writes are never served stale. + const roles = await getCachedRoles(ctx, () => searchRoles(null, ctx)) + const role: any = id - ? (await searchRoles(null, ctx)).find((item: any) => item.id === id) - : (await searchRoles(null, ctx)).find((item: any) => item.slug === slug) + ? roles.find((item: any) => item.id === id) + : roles.find((item: any) => item.slug === slug) return role } catch (error) { diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index b86023ba..837fed1b 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -1,17 +1,26 @@ import { ForbiddenError } from '@vtex/api' import { json } from 'co-body' +import { + getCachedActiveUserByEmail, + getCachedActiveUserForPermissions, + setActiveUserCacheTtl, +} from '../../services/activeUserCache' import { getCachedAppSettings } from '../../services/appSettingsCache' -import { getRole } from '../Queries/Roles' -import { getSessionWatcher } from '../Queries/Settings' -import { generateClUser, getUserOrganizationsData } from './utils' import { - getActiveUserByEmail, - getUserByEmail, - getB2BUserById, -} from '../Queries/Users' -import { getUser, setActiveUserByOrganization } from '../Mutations/Users' + getCachedB2BSettings, + getCachedCostCenter, + getCachedOrganization, +} from '../../services/organizationsCache' +import { getCachedRegionId } from '../../services/regionCache' +import { getCachedSalesChannel } from '../../services/salesChannelCache' +import { getCachedSessionWatcher } from '../../services/sessionWatcherCache' import { toHash } from '../../utils' +import { createTimer, getTimer } from '../../utils/requestTimings' +import { getUser, setActiveUserByOrganization } from '../Mutations/Users' +import { getRole } from '../Queries/Roles' +import { getActiveUserByEmail, getB2BUserById } from '../Queries/Users' +import { generateClUser, getUserOrganizationsData } from './utils' export const Routes = { PROFILE_DOCUMENT_TYPE: 'cpf', @@ -51,11 +60,13 @@ export const Routes = { throw new Error('Email not defined') } - const userData: any = await getUserByEmail( - null, - { email: params.email }, - ctx - ) + // Same array shape getUserByEmail returned, but served from the short-lived + // permissions cache: this route is called per request by sibling B2B apps. + const userData: any = [ + await getCachedActiveUserForPermissions(ctx, params.email, () => + getActiveUserByEmail(null, { email: params.email }, ctx) + ), + ] if (!userData.length) { logger.warn({ @@ -101,12 +112,15 @@ export const Routes = { masterDataExtended, checkout, profileSystem, - salesChannel: salesChannelClient, }, req, vtex: { logger }, } = ctx + // Provided by the withRequestTimings middleware, which emits the timings for + // both outcomes. The fallback keeps this callable outside that chain. + const timer = getTimer(ctx) ?? createTimer() + const response: any = { public: { facets: { @@ -150,7 +164,10 @@ export const Routes = { ctx.set('Content-Type', 'application/json') ctx.set('Cache-Control', 'no-cache, no-store') - const isWatchActive = await getSessionWatcher(null, null, ctx) + const isWatchActive = await timer.track( + 'getSessionWatcher', + getCachedSessionWatcher(ctx) + ) if (!isWatchActive) { ctx.response.body = response @@ -178,6 +195,13 @@ export const Routes = { const ignoreB2B = body?.public?.removeB2B?.value + /** + * Written into the session by setCurrentOrganization on every organization + * switch. Used as part of the active-user cache key so that switching + * organization misses the cache instead of reading the previous one. + */ + const currentCostCenter = body?.public?.b2bCurrentCostCenter?.value ?? null + if (ignoreB2B) { ctx.response.body = response ctx.response.status = 200 @@ -231,11 +255,39 @@ export const Routes = { return } + // Kick off request/user-independent lookups now so their (cold) latency + // overlaps with the user + organization lookups below, instead of being + // awaited serially in a single batch. These are read-only and only fire + // once we know this is an authenticated (email-bearing) session. + const salesChannelsPromise = timer.track( + 'getSalesChannel', + getCachedSalesChannel(ctx) + ) + // b2bSettings is only consumed by the (conditional) clearCart branch, so it + // may never be awaited; guard against unhandled rejections. + const b2bSettingsPromise = timer + .track( + 'getB2BSettings', + getCachedB2BSettings(ctx, () => organizations.getB2BSettings()) + ) + .catch((error) => { + logger.error({ error, message: 'setProfile.getB2BSettings' }) + + return null + }) + const appSettingsPromise = timer.track( + 'getCachedAppSettings', + getCachedAppSettings(ctx) + ) + if (user === null) { - user = (await getActiveUserByEmail(null, { email }, ctx).catch( - (error) => { - logger.warn({ message: 'setProfile.getUserByEmailError', error }) - } + user = (await timer.track( + 'getActiveUserByEmail', + getCachedActiveUserByEmail(ctx, email, currentCostCenter, () => + getActiveUserByEmail(null, { email }, ctx).catch((error) => { + logger.warn({ message: 'setProfile.getUserByEmailError', error }) + }) + ) )) as { orgId: string costId: string @@ -256,22 +308,24 @@ export const Routes = { response['storefront-permissions'].organization.value = user.orgId const getOrganization = async (orgId: any): Promise => { - return masterDataExtended - .getDocumentById('organizations', orgId, [ - 'name', - 'tradeName', - 'status', - 'priceTables', - 'salesChannel', - 'collections', - 'sellers', - ]) - .catch((error) => { - logger.error({ - error, - message: 'setProfile.graphqlGetOrganizationById', + return getCachedOrganization(ctx, String(orgId), () => + masterDataExtended + .getDocumentById('organizations', orgId, [ + 'name', + 'tradeName', + 'status', + 'priceTables', + 'salesChannel', + 'collections', + 'sellers', + ]) + .catch((error) => { + logger.error({ + error, + message: 'setProfile.graphqlGetOrganizationById', + }) }) - }) + ) } const hash = toHash(`${user.orgId}|${user.costId}`) @@ -279,22 +333,54 @@ export const Routes = { response['storefront-permissions'].hash.value = hash - const [ - organizationResponse, - costCenterResponse, - salesChannels, - marketingTagsResponse, - b2bSettingsResponse, - appSettings, - ] = await Promise.all([ - getOrganization(user.orgId), - organizations.getCostCenterById(user.costId), - salesChannelClient.getSalesChannel(), - organizations.getMarketingTags(user.costId), - organizations.getB2BSettings(), - getCachedAppSettings(ctx), + // Best-effort context, so a request that throws before finishing still + // reports which organization it was serving. Refined at the end. + timer.meta.extra = { + hasOrderFormId: !!orderFormId, + hashChanged, + orgId: user.orgId, + } + + // Marketing tags only feed a fire-and-forget cart update further down, so + // keep them off the critical path (do not await here). + const marketingTagsPromise = organizations + .getMarketingTags(user.costId) + .catch((error) => { + logger.error({ error, message: 'setProfile.getMarketingTags' }) + + return null + }) + + // Read into locals so the cache fetcher below does not close over `user`, + // which is reassigned further down. + const resolvedCostId = user.costId + + // Only these two genuinely depend on the resolved user (orgId/costId). + const [organizationResponse, costCenterResponse] = await Promise.all([ + timer.track('getOrganization', getOrganization(user.orgId)), + timer.track( + 'getCostCenterById', + getCachedCostCenter(ctx, String(resolvedCostId), () => + organizations.getCostCenterById(resolvedCostId) + ) + ), ]) + // These were started earlier; by now their latency is largely hidden + // behind the user + organization lookups above. Tracking the wait itself + // shows how much (if any) still lands on the critical path. + const [salesChannels, appSettings] = await timer.track( + 'awaitIndependent', + Promise.all([salesChannelsPromise, appSettingsPromise]) + ) + + setActiveUserCacheTtl((appSettings as any)?.sessionUserCacheTtlMs) + + // Hand the account's limits to the middleware that emits the timings. + timer.meta.sampleRate = (appSettings as any)?.sessionTimingsSampleRate + timer.meta.slowThresholdMs = (appSettings as any) + ?.sessionTimingsSlowThresholdMs + // in case the cost center is not found, we need to find a valid cost center for the user if ( Object.values(costCenterResponse.data?.getCostCenterById ?? {}).every( @@ -302,7 +388,10 @@ export const Routes = { ) ) { try { - const usersByEmail = await organizations.getOrganizationsByEmail(email) + const usersByEmail = await timer.track( + 'getOrganizationsByEmail', + organizations.getOrganizationsByEmail(email) + ) // when cost center comes without a name, it's because the cost center is deleted const usersData = usersByEmail.data.getOrganizationsByEmail.find( @@ -330,15 +419,16 @@ export const Routes = { const needsOrgData = organizationInactive || costCenterInvalid if (needsOrgData) { - userOrgsData = await getUserOrganizationsData(email, ctx).catch( - (error) => { + userOrgsData = await timer.track( + 'getUserOrganizationsData', + getUserOrganizationsData(email, ctx).catch((error) => { logger.error({ error, message: 'setProfile.getUserOrganizationsData', }) return { validCostCenterId: null, activeOrganization: null } - } + }) ) } @@ -352,8 +442,11 @@ export const Routes = { const validOrganization = userOrgsData?.activeOrganization if (validOrganization) { - organization = (await getOrganization(validOrganization.id))?.data - ?.getOrganizationById + // getOrganization reads Master Data directly, so it returns the document + // itself. Unwrapping `.data.getOrganizationById` here is left over from + // when this went through the b2b-organizations GraphQL client, and it + // resolved to undefined, throwing on the `organization.name` access below. + organization = await getOrganization(validOrganization.id) await setActiveUserByOrganization( null, @@ -384,10 +477,9 @@ export const Routes = { tradeName = organization.tradeName if (organization.priceTables?.length) { - const userWithPriceTable = (await getB2BUserById( - null, - { id: user.id }, - ctx + const userWithPriceTable = (await timer.track( + 'getB2BUserById', + getB2BUserById(null, { id: user.id }, ctx) )) as { selectedPriceTable: string } const MAX_PRICE_TABLES = 3 @@ -424,8 +516,12 @@ export const Routes = { if (sellersArray.length > 0) { const sellersList = sellersArray - const { disableSellersNameFacets, disablePrivateSellersFacets } = - await Routes.appSettings(ctx) + // Reuse the already-fetched (cached) appSettings instead of issuing a + // second, uncached getAppSettings round-trip on the sellers path. + const disableSellersNameFacets = (appSettings as any) + ?.disableSellersNameFacets + const disablePrivateSellersFacets = (appSettings as any) + ?.disablePrivateSellersFacets if (!disableSellersNameFacets) { const sellersName = sellersList.map( @@ -468,6 +564,9 @@ export const Routes = { const enableRegionOverwriteFlag = (appSettings as any)?.enableRegionOverwrite ?? false + const deferRegionToCheckoutSessionFlag = + (appSettings as any)?.deferRegionToCheckoutSession ?? false + const publicCostCenterAddressId = body?.public?.costCenterAddressId?.value const requestedAddressId = enableCostCenterAddressSelection ? publicCostCenterAddressId @@ -588,14 +687,18 @@ export const Routes = { if (hashChanged && orderFormId) { try { + const b2bSettingsResponse = await b2bSettingsPromise const b2bSettings = (b2bSettingsResponse as any)?.data?.getB2BSettings const { uiSettings: { clearCart }, } = b2bSettings ?? { uiSettings: { clearCart: null } } if (clearCart) { - await Promise.all(salesChannelPromise) - await checkout.clearCart(orderFormId) + await timer.track( + 'updateSalesChannel', + Promise.all(salesChannelPromise) + ) + await timer.track('clearCart', checkout.clearCart(orderFormId)) } } catch (error) { logger.error({ @@ -609,16 +712,61 @@ export const Routes = { // checkout-session will use public.postalCode and public.country for checkout.regionId. We also do not update the cart with an address. if (selectedAddress && orderFormId) { const address = selectedAddress - const marketingTags: any = (marketingTagsResponse as any)?.data - ?.getMarketingTags?.tags - if (!usePublicPostalCodeForRegion && regionLookupSalesChannel) { + /** + * vtex.checkout-session performs this exact same region lookup (and caches + * it), and it is the app that produces the canonical `checkout.regionId` + * that the platform and the segment actually read. When this is enabled we + * publish the cost center locality instead of resolving the region here, + * which removes a call from the session transform. + * + * It also fixes an inconsistency: today the cart is regionalized from the + * cost center address while vtex.search-session, which reads the locality + * from the public namespace, sees nothing for B2B users. + * + * Requires a country and postal code, since that is the input contract of + * checkout-session; otherwise we fall back to resolving it ourselves. + */ + const deferRegionToCheckoutSession = + deferRegionToCheckoutSessionFlag && + !usePublicPostalCodeForRegion && + !!address.country && + !!address.postalCode + + if (deferRegionToCheckoutSession) { + // Omit `regionId` rather than sending an empty value, so we never clear a + // region another app (or the storefront) already resolved. + delete response.public.regionId + + response.public.country = { value: address.country } + response.public.postalCode = { value: address.postalCode } + + logger.info({ + costId: user.costId, + message: 'setProfile.regionDeferredToCheckoutSession', + }) + } else if (!usePublicPostalCodeForRegion && regionLookupSalesChannel) { try { - const [regionId] = await checkout.getRegionId( - address.country, - address.postalCode, - regionLookupSalesChannel.toString(), - address.geoCoordinates + const regionSalesChannel = regionLookupSalesChannel.toString() + + const [regionId] = await timer.track( + 'getRegionId', + getCachedRegionId( + ctx, + { + country: address.country, + geoCoordinates: address.geoCoordinates, + postalCode: address.postalCode, + salesChannel: regionSalesChannel, + }, + () => + checkout.getRegionId( + address.country, + address.postalCode, + regionSalesChannel, + address.geoCoordinates + ) + ) ) if (regionId?.id) { @@ -643,13 +791,21 @@ export const Routes = { }) } + const utmCampaign = user.orgId + const utmMedium = user.costId + promises.push( - checkout - .updateOrderFormMarketingData(orderFormId, { - attachmentId: 'marketingData', - marketingTags: marketingTags || [], - utmCampaign: user.orgId, - utmMedium: user.costId, + marketingTagsPromise + .then((marketingTagsResponse) => { + const marketingTags: any = (marketingTagsResponse as any)?.data + ?.getMarketingTags?.tags + + return checkout.updateOrderFormMarketingData(orderFormId, { + attachmentId: 'marketingData', + marketingTags: marketingTags || [], + utmCampaign, + utmMedium, + }) }) .catch((error) => { logger.error({ @@ -685,34 +841,51 @@ export const Routes = { } } - const clUser = await generateClUser({ - businessDocument, - businessName, - clId: user?.clId ?? '', - ctx, - phoneNumber: phoneNumber ?? null, - stateRegistration, - tradeName, - isCorporate, - }) - - if (clUser && orderFormId) { - const phoneNumberFormatted = - phoneNumber || clUser.phone || clUser.homePhone || `+1${'0'.repeat(10)}` + // The CL profile only feeds the fire-and-forget cart update below, so it + // must not block the response (it measured up to ~1s in spikes). It is also + // skipped entirely when there is no cart to update, which is the only thing + // its result was ever used for. Values are read into consts because the + // surrounding variables are reassigned `let`s and must not be closed over. + if (orderFormId) { + const clId = user?.clId ?? '' + const clBusinessDocument = businessDocument + const clBusinessName = businessName + const clDocumentType = documentType + const clPhoneNumber = phoneNumber + const clStateRegistration = stateRegistration + const clTradeName = tradeName promises.push( - checkout - .updateOrderFormProfile(orderFormId, { - ...clUser, - businessDocument: - (businessDocument || clUser.businessDocument) ?? null, - documentType: documentType ?? undefined, - phone: phoneNumberFormatted, - stateInscription: - stateRegistration ?? - clUser.stateInscription ?? - '0'.repeat(9) ?? - null, + generateClUser({ + businessDocument: clBusinessDocument, + businessName: clBusinessName, + clId, + ctx, + phoneNumber: clPhoneNumber ?? null, + stateRegistration: clStateRegistration, + tradeName: clTradeName, + isCorporate, + }) + .then((clUser) => { + if (!clUser) { + return undefined + } + + const phoneNumberFormatted = + clPhoneNumber || + clUser.phone || + clUser.homePhone || + `+1${'0'.repeat(10)}` + + return checkout.updateOrderFormProfile(orderFormId, { + ...clUser, + businessDocument: + (clBusinessDocument || clUser.businessDocument) ?? null, + documentType: clDocumentType ?? undefined, + phone: phoneNumberFormatted, + stateInscription: + clStateRegistration ?? clUser.stateInscription ?? '0'.repeat(9), + }) }) .catch((error) => { logger.error({ @@ -726,10 +899,23 @@ export const Routes = { // Don't await promises, to avoid session timeout Promise.all(promises) - logger.info({ - 'setProfile.body': JSON.stringify(body), - 'setProfile.output': JSON.stringify(response), - }) + timer.meta.extra = { + costId: user.costId, + hasOrderFormId: !!orderFormId, + hashChanged, + orgId: user.orgId, + } + + // Off by default: this used to run on every session transform, which on a + // route this hot means two JSON.stringify calls per request plus a log line + // carrying the whole session in and out, including the shopper's email and + // organization data. Enable it per account only while debugging. + if ((appSettings as any)?.logSessionPayloads) { + logger.info({ + 'setProfile.body': JSON.stringify(body), + 'setProfile.output': JSON.stringify(response), + }) + } ctx.response.body = response ctx.response.status = 200 diff --git a/node/service.json b/node/service.json index 8a3a2d48..6452577e 100644 --- a/node/service.json +++ b/node/service.json @@ -1,8 +1,8 @@ { "stack": "nodejs", - "memory": 256, - "ttl": 60, - "timeout": 45, + "memory": 1024, + "ttl": 300, + "timeout": 60, "cpu": { "type": "shared", "value": 5, diff --git a/node/services/activeUserCache.ts b/node/services/activeUserCache.ts new file mode 100644 index 00000000..33a8a402 --- /dev/null +++ b/node/services/activeUserCache.ts @@ -0,0 +1,69 @@ +import { + ACTIVE_USER_CACHE_TTL_IN_MINUTES, + ACTIVE_USER_CACHE_TTL_IN_MS, + PERMISSIONS_USER_CACHE_TTL_IN_MS, +} from '../utils/constants' +import { createCachedResource } from './cache' + +/** + * Resolving the active user runs a *paginated* Master Data search, which was + * measured spiking well past a second, and the session transform runs several + * times per navigation for the same email. + * + * The key is `email + b2bCurrentCostCenter`. That second part is written into the + * session by `setCurrentOrganization` whenever the user switches organization, so + * a switch produces a different key and therefore a miss, rather than serving the + * previous organization from cache. Because invalidation is exact, both layers + * can be used and the TTL only has to cover changes that bypass that mutation. + */ +const cachedActiveUser = createCachedResource('active-user', { + // Small payloads (~400B), one per shopper. + maxEntries: 10000, + memoryTtlMs: ACTIVE_USER_CACHE_TTL_IN_MS, + vbaseTtlMinutes: ACTIVE_USER_CACHE_TTL_IN_MINUTES, +}) + +let configuredTtlMs: number | undefined + +/** + * The TTL is configurable, but this lookup happens before app settings are + * resolved (awaiting them first would put them back on the critical path). So + * the configured value is recorded once a request has read the settings and + * applies from the next request onwards, which is fine for a TTL knob. + */ +export const setActiveUserCacheTtl = (ttlMs?: unknown) => { + if (typeof ttlMs === 'number' && ttlMs >= 0) { + configuredTtlMs = ttlMs + } +} + +export const getCachedActiveUserByEmail = async ( + ctx: Context, + email: string, + currentCostCenter: string | null, + fetcher: () => Promise +): Promise => + cachedActiveUser(ctx, `${email}|${currentCostCenter ?? 'default'}`, fetcher, { + memoryTtlMs: configuredTtlMs ?? ACTIVE_USER_CACHE_TTL_IN_MS, + }) + +/** + * Variant for permission checks (checkPermissions route). Those requests carry + * only app + email, so there is no session cost center to key on and an + * organization switch cannot invalidate by key. Memory-only with a short TTL, + * so stale permissions are bounded to that window and never extended by a + * cross-pod layer. + */ +const cachedPermissionsUser = createCachedResource( + 'active-user-permissions', + { + maxEntries: 10000, + memoryTtlMs: PERMISSIONS_USER_CACHE_TTL_IN_MS, + } +) + +export const getCachedActiveUserForPermissions = async ( + ctx: Context, + email: string, + fetcher: () => Promise +): Promise => cachedPermissionsUser(ctx, email, fetcher) diff --git a/node/services/appSettingsCache.ts b/node/services/appSettingsCache.ts index 2f6ccf50..7f9ff2cc 100644 --- a/node/services/appSettingsCache.ts +++ b/node/services/appSettingsCache.ts @@ -1,30 +1,30 @@ -import { LRUCache } from '@vtex/api' +import { APP_SETTINGS_CACHE_TTL_IN_MINUTES } from '../utils/constants' +import { createCachedResource } from './cache' -const APP_SETTINGS_CACHE_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes -const APP_SETTINGS_CACHE_MAX_ENTRIES = 5000 +const APP_SETTINGS_MEMORY_CACHE_TTL_MS = 5 * 60 * 1000 -const settingsCache = new LRUCache>({ - max: APP_SETTINGS_CACHE_MAX_ENTRIES, -}) +type AppSettings = Record /** - * Returns app settings (manifest settingsSchema) with in-memory LRU cache - * to avoid calling the Apps API on every setProfile request. - * Cache key: account-workspace-appId. TTL: 5 minutes. + * App settings (manifest settingsSchema). The Apps API was measured as one of + * the most expensive calls on a cold pod, so this uses both layers: warm pods do + * no I/O, and cold pods read the shared VBase entry instead of the Apps API. */ -export const getCachedAppSettings = async (ctx: Context): Promise> => { +const cachedAppSettings = createCachedResource('app-settings', { + // One entry per account/workspace served by this pod. + maxEntries: 50, + memoryTtlMs: APP_SETTINGS_MEMORY_CACHE_TTL_MS, + vbaseTtlMinutes: APP_SETTINGS_CACHE_TTL_IN_MINUTES, +}) + +export const getCachedAppSettings = async ( + ctx: Context +): Promise => { const appId = process.env.VTEX_APP_ID ?? '' - const vtex = ctx.vtex - const account = vtex.account - const workspace = vtex.workspace - const cacheKey = `${account}-${workspace}-${appId}` - const cached = await settingsCache.getOrSet(cacheKey, () => - ctx.clients.apps.getAppSettings(appId).then((res) => ({ - value: (res ?? {}) as Record, - maxAge: APP_SETTINGS_CACHE_MAX_AGE_MS, - })) + const cached = await cachedAppSettings(ctx, appId, () => + ctx.clients.apps.getAppSettings(appId).then((res) => (res ?? {}) as AppSettings) ) - return (cached != null && typeof cached === 'object' ? cached : {}) as Record + return cached != null && typeof cached === 'object' ? cached : {} } diff --git a/node/services/cache.ts b/node/services/cache.ts new file mode 100644 index 00000000..c4fbc0d6 --- /dev/null +++ b/node/services/cache.ts @@ -0,0 +1,130 @@ +import { LRUCache } from '@vtex/api' + +import { VBASE_CACHE_BUCKET } from '../utils/constants' +import { staleFromVBaseWhileRevalidate } from '../utils/staleFromVBaseWhileRevalidate' + +const DEFAULT_MAX_ENTRIES = 1000 + +/** + * Every cache created through createCachedResource registers itself here, so + * hit rates and sizes can be reported periodically. getStats() resets its + * counters on read, which makes each report cover exactly one interval. + */ +const registeredCaches = new Map>() + +export const collectCacheStats = () => + Array.from(registeredCaches.entries()).map(([name, cache]) => + cache.getStats(name) + ) + +/** + * Serialized length of a cached value, used as its weight when a cache is + * bounded by bytes. Only runs when an entry is stored, never on a cache hit. + */ +const approximateSize = (value: unknown): number => { + try { + return JSON.stringify(value)?.length || 1 + } catch (error) { + return 1 + } +} + +export interface CachedResourceOptions { + /** Entries kept in the per-pod LRU. Ignored when `maxSizeBytes` is set. */ + maxEntries?: number + /** + * Byte budget for the whole cache, measured as serialized length. + * + * Prefer this over `maxEntries` when payload size varies a lot: cost center + * documents were measured spanning roughly 400B to 29KB, so a fixed entry + * count makes the memory footprint swing by ~70x. Budgeting bytes means one + * unusually large document evicts others instead of growing the heap. + * + * Note that a parsed object costs more heap than its serialized length, so + * size the budget with room to spare. + */ + maxSizeBytes?: number + /** Per-pod in-memory TTL. Set to 0 to bypass caching entirely. */ + memoryTtlMs: number + /** + * When set, adds a cross-pod VBase stale-while-revalidate layer behind the + * in-memory one. + * + * Only worth it when the origin is expensive (Apps API, Master Data, another + * app's GraphQL). Omit it for resources that already live in VBase, where it + * would just swap one VBase read for another. + */ + vbaseTtlMinutes?: number +} + +/** + * Builds a cached view of a resource with up to two layers: + * + * 1. Per-pod in-memory LRU, so a warm pod does no I/O at all. This is what makes + * the repeated calls within a single storefront navigation cheap. + * 2. Optional cross-pod VBase stale-while-revalidate. When the in-memory entry + * expires the read falls through to here, which returns the stored value + * straight away and refreshes in the background, so the expensive origin call + * never lands on a request. + * + * Keys are scoped per account and workspace to keep tenants isolated. Each + * resource owns its own LRU, so the resource name is not part of the memory key; + * it is only needed in the VBase path, where all resources share one bucket. + */ +export const createCachedResource = ( + name: string, + options: CachedResourceOptions +) => { + // With a `length` function, lru-cache treats `max` as a total size budget + // rather than an entry count. + const cache = new LRUCache( + options.maxSizeBytes + ? ({ + length: approximateSize, + max: options.maxSizeBytes, + } as any) + : { max: options.maxEntries ?? DEFAULT_MAX_ENTRIES } + ) + + registeredCaches.set(name, cache as LRUCache) + + return async ( + ctx: Context, + key: string, + fetcher: () => Promise, + overrides?: { memoryTtlMs?: number } + ): Promise => { + const memoryTtlMs = overrides?.memoryTtlMs ?? options.memoryTtlMs + + const readThrough = () => + options.vbaseTtlMinutes + ? staleFromVBaseWhileRevalidate( + ctx.clients.vbase, + VBASE_CACHE_BUCKET, + `${name}-${key}`, + fetcher, + undefined, + { + expirationInMinutes: options.vbaseTtlMinutes, + logger: ctx.vtex.logger, + } + ) + : fetcher() + + if (memoryTtlMs <= 0) { + return readThrough() + } + + const { account, workspace } = ctx.vtex + + // getOrSet is typed as `V | void`, so normalize it for callers. + const cached = await cache.getOrSet(`${account}-${workspace}-${key}`, () => + readThrough().then((value) => ({ + maxAge: memoryTtlMs, + value, + })) + ) + + return cached as unknown as T | undefined + } +} diff --git a/node/services/organizationsCache.ts b/node/services/organizationsCache.ts new file mode 100644 index 00000000..72576e8d --- /dev/null +++ b/node/services/organizationsCache.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + B2B_SETTINGS_CACHE_TTL_IN_MINUTES, + B2B_SETTINGS_CACHE_TTL_IN_MS, + COST_CENTER_CACHE_MAX_SIZE_BYTES, + ORGANIZATION_CACHE_TTL_IN_MINUTES, + ORGANIZATION_CACHE_TTL_IN_MS, +} from '../utils/constants' +import { createCachedResource } from './cache' + +/** + * These are all cross-app calls into vtex.b2b-organizations / Master Data. Once + * the account-level lookups were cached they became the most expensive remaining + * steps of the session transform (getCostCenterById alone measured around a + * second), and the transform runs several times per navigation. + * + * Both layers are used: warm pods do no I/O, and cold pods read the entry a + * sibling pod already populated instead of paying the cross-app cost. + */ +const cachedB2BSettings = createCachedResource('b2b-settings', { + maxEntries: 100, + memoryTtlMs: B2B_SETTINGS_CACHE_TTL_IN_MS, + vbaseTtlMinutes: B2B_SETTINGS_CACHE_TTL_IN_MINUTES, +}) + +// Organization documents are small and tightly clustered, so a count is a +// predictable bound here. +const cachedOrganization = createCachedResource('organization', { + maxEntries: 10000, + memoryTtlMs: ORGANIZATION_CACHE_TTL_IN_MS, + vbaseTtlMinutes: ORGANIZATION_CACHE_TTL_IN_MINUTES, +}) + +// Cost centers carry their addresses, so a single document can be far larger +// than the rest. Bounded by bytes so the footprint cannot swing with the data. +const cachedCostCenter = createCachedResource('cost-center', { + maxSizeBytes: COST_CENTER_CACHE_MAX_SIZE_BYTES, + memoryTtlMs: ORGANIZATION_CACHE_TTL_IN_MS, + vbaseTtlMinutes: ORGANIZATION_CACHE_TTL_IN_MINUTES, +}) + +export const getCachedB2BSettings = async ( + ctx: Context, + fetcher: () => Promise +): Promise => cachedB2BSettings(ctx, 'settings', fetcher) + +export const getCachedOrganization = async ( + ctx: Context, + orgId: string, + fetcher: () => Promise +): Promise => cachedOrganization(ctx, orgId, fetcher) + +export const getCachedCostCenter = async ( + ctx: Context, + costId: string, + fetcher: () => Promise +): Promise => cachedCostCenter(ctx, costId, fetcher) diff --git a/node/services/regionCache.ts b/node/services/regionCache.ts new file mode 100644 index 00000000..e1b495cc --- /dev/null +++ b/node/services/regionCache.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + REGION_CACHE_TTL_IN_MINUTES, + REGION_CACHE_TTL_IN_MS, +} from '../utils/constants' +import { createCachedResource } from './cache' + +const cachedRegion = createCachedResource('region', { + // Small payloads, keyed by locality rather than by user, so many shoppers share + // one entry. + maxEntries: 10000, + memoryTtlMs: REGION_CACHE_TTL_IN_MS, + vbaseTtlMinutes: REGION_CACHE_TTL_IN_MINUTES, +}) + +export interface RegionCacheKey { + country: string + geoCoordinates: [number, number] | null + postalCode: string | null + salesChannel: string +} + +/** + * checkout's region lookup depends only on its four inputs, so the whole tuple is + * the cache key. Cost centers in the same city and sales channel therefore share + * one entry instead of each paying the round-trip on every session transform. + */ +export const getCachedRegionId = async ( + ctx: Context, + key: RegionCacheKey, + fetcher: () => Promise +): Promise => { + const { country, geoCoordinates, postalCode, salesChannel } = key + + const cacheKey = [ + country, + postalCode ?? '', + salesChannel, + geoCoordinates ? geoCoordinates.join(';') : '', + ].join('|') + + return cachedRegion(ctx, cacheKey, fetcher) +} diff --git a/node/services/rolesCache.ts b/node/services/rolesCache.ts new file mode 100644 index 00000000..1ef7e398 --- /dev/null +++ b/node/services/rolesCache.ts @@ -0,0 +1,21 @@ +import { ROLES_CACHE_TTL_IN_MS } from '../utils/constants' +import { createCachedResource } from './cache' + +/** + * Roles are account-level, change only through the admin, and are read on every + * permission check. The source is VBase (with a Master Data fallback), so an + * in-memory layer is the only one that helps here. + */ +const cachedRoles = createCachedResource('roles', { + maxEntries: 100, + memoryTtlMs: ROLES_CACHE_TTL_IN_MS, +}) + +export const getCachedRoles = async ( + ctx: Context, + fetcher: () => Promise +): Promise => { + const cached = await cachedRoles(ctx, 'all', fetcher) + + return cached ?? [] +} diff --git a/node/services/salesChannelCache.ts b/node/services/salesChannelCache.ts new file mode 100644 index 00000000..7727478e --- /dev/null +++ b/node/services/salesChannelCache.ts @@ -0,0 +1,38 @@ +import { + SALES_CHANNEL_CACHE_TTL_IN_MINUTES, + SALES_CHANNEL_MEMORY_CACHE_TTL_IN_MS, +} from '../utils/constants' +import { createCachedResource } from './cache' + +type SalesChannelResult = Record + +/** + * The sales channel list comes from an uncached private catalog endpoint and is + * identical for every user of the account, so it does not need to be fetched on + * every session transform. + */ +const cachedSalesChannel = createCachedResource( + 'sales-channel', + { + maxEntries: 100, + memoryTtlMs: SALES_CHANNEL_MEMORY_CACHE_TTL_IN_MS, + vbaseTtlMinutes: SALES_CHANNEL_CACHE_TTL_IN_MINUTES, + } +) + +export const getCachedSalesChannel = async ( + ctx: Context +): Promise => { + const { + clients: { salesChannel }, + } = ctx + + const cached = await cachedSalesChannel( + ctx, + 'list', + () => + salesChannel.getSalesChannel() as unknown as Promise + ) + + return cached ?? {} +} diff --git a/node/services/sessionWatcherCache.ts b/node/services/sessionWatcherCache.ts new file mode 100644 index 00000000..d93ee228 --- /dev/null +++ b/node/services/sessionWatcherCache.ts @@ -0,0 +1,32 @@ +import { getSessionWatcher } from '../resolvers/Queries/Settings' +import { SESSION_WATCHER_CACHE_TTL_IN_MS } from '../utils/constants' +import { createCachedResource } from './cache' + +/** + * `getSessionWatcher` reads its flag straight from VBase on every single session + * transform, which measured as the most expensive step once the other calls were + * cached. A VBase-backed layer would not help here (it would just swap one VBase + * read for another), so this is in-memory only: a warm pod does no I/O and a cold + * pod pays a single read. + * + * The TTL is deliberately short: this flag is an operational kill switch, so + * disabling it must still take effect quickly. + */ +const cachedWatcher = createCachedResource('session-watcher', { + maxEntries: 100, + memoryTtlMs: SESSION_WATCHER_CACHE_TTL_IN_MS, +}) + +export const getCachedSessionWatcher = async ( + ctx: Context +): Promise => { + const cached = await cachedWatcher(ctx, 'active', () => + Promise.resolve(getSessionWatcher(null, null, ctx)).then( + // Anything other than an explicit `false` keeps the watcher active, + // preserving the original default-on behaviour. + (value) => value !== false + ) + ) + + return cached !== false +} diff --git a/node/typings/staleFromVBaseWhileRevalidate.ts b/node/typings/staleFromVBaseWhileRevalidate.ts new file mode 100644 index 00000000..8ef13361 --- /dev/null +++ b/node/typings/staleFromVBaseWhileRevalidate.ts @@ -0,0 +1,8 @@ +export interface StaleRevalidateData { + data: T + /** + * Serialized as an ISO string once it round-trips through VBase's JSON + * storage, so consumers must accept both shapes. + */ + ttl: Date | string +} diff --git a/node/utils/constants.ts b/node/utils/constants.ts index 24b33c42..032869c7 100644 --- a/node/utils/constants.ts +++ b/node/utils/constants.ts @@ -8,6 +8,85 @@ export const CUSTOMER_REQUIRED_FIELDS = [ ] export const ROLES_VBASE_ID = 'allRolesVbId' +/** + * Caching for the session transform. This route runs several times during a + * single storefront navigation, so every avoided round-trip counts. + * + * Two rules of thumb: + * - Add the cross-pod VBase layer only when the origin is expensive (Apps API, + * Master Data, another app's GraphQL). For data that already lives in VBase, + * an in-memory layer is the only thing that helps. + * - Keep the TTL short for anything an operator flips or a user changes. + */ +// Shared VBase bucket for cross-pod stale-while-revalidate caches +export const VBASE_CACHE_BUCKET = 'sfp-cache' + +// Sales channel list changes very rarely, so it can be cached aggressively. +export const SALES_CHANNEL_CACHE_TTL_IN_MINUTES = 6 * 60 +export const SALES_CHANNEL_MEMORY_CACHE_TTL_IN_MS = 5 * 60 * 1000 + +/** + * App settings are feature flags an operator may flip, so keep this short: + * effective propagation delay is roughly this TTL plus the in-memory TTL. + */ +export const APP_SETTINGS_CACHE_TTL_IN_MINUTES = 5 + +// In-memory only (VBase-sourced), and short: this flag is a kill switch. +export const SESSION_WATCHER_CACHE_TTL_IN_MS = 60 * 1000 + +// In-memory only (VBase-sourced); roles change rarely and only via the admin. +export const ROLES_CACHE_TTL_IN_MS = 5 * 60 * 1000 + +/** + * Resolves the *active* organization of a user. The cache key includes the + * session's `public.b2bCurrentCostCenter`, which `setCurrentOrganization` writes + * on every organization switch, so a switch changes the key and misses the cache + * instead of reading a stale entry. That makes key-based invalidation exact and + * lets the TTL be generous. + * + * The TTL is only a safety net for changes that do *not* go through that + * mutation (an admin editing the user's organizations, or the inactive-org + * fallback in setProfile). Set `sessionUserCacheTtlMs` to 0 to disable. + */ +export const ACTIVE_USER_CACHE_TTL_IN_MS = 5 * 60 * 1000 +export const ACTIVE_USER_CACHE_TTL_IN_MINUTES = 5 + +/** + * Variant used by permission checks, which have no session cost center to key + * on, so an organization switch cannot invalidate by key. Kept memory-only and + * short so stale permissions are bounded to this window. + */ +export const PERMISSIONS_USER_CACHE_TTL_IN_MS = 60 * 1000 + +/** + * The region lookup is a deterministic function of country, postal code, sales + * channel and geo coordinates, so it caches cleanly on those. It only goes stale + * when the merchant changes logistics configuration. + */ +export const REGION_CACHE_TTL_IN_MS = 30 * 60 * 1000 +export const REGION_CACHE_TTL_IN_MINUTES = 30 + +// Account-level B2B settings, edited from the admin only. +export const B2B_SETTINGS_CACHE_TTL_IN_MS = 5 * 60 * 1000 +export const B2B_SETTINGS_CACHE_TTL_IN_MINUTES = 5 + +/** + * Organization and cost center data (status, price tables, sales channel, + * addresses) is admin-edited and was measured as the most expensive remaining + * step. TTLs are kept to a minute because deactivating an organization should + * take effect quickly. + */ +export const ORGANIZATION_CACHE_TTL_IN_MS = 60 * 1000 +export const ORGANIZATION_CACHE_TTL_IN_MINUTES = 2 + +/** + * Cost center documents were measured between roughly 400B and 29KB, because the + * addresses list varies wildly, so this cache is bounded by bytes instead of by + * entry count. Organization documents measured 187B to 480B, a tight enough + * spread that a plain entry count is predictable. + */ +export const COST_CENTER_CACHE_MAX_SIZE_BYTES = 8 * 1024 * 1024 + // License Manager constants export const B2B_ORGANIZATIONS_PRODUCT_ID = 97 export const B2B_LM_PRODUCT_CODE = B2B_ORGANIZATIONS_PRODUCT_ID diff --git a/node/utils/requestTimings.ts b/node/utils/requestTimings.ts new file mode 100644 index 00000000..dc9f19e4 --- /dev/null +++ b/node/utils/requestTimings.ts @@ -0,0 +1,118 @@ +import type { Logger } from '@vtex/api/lib/service/logger/logger' + +export interface RequestTimings { + [step: string]: number +} + +export interface TimerMeta { + extra?: Record + sampleRate?: number + slowThresholdMs?: number +} + +export interface Timer { + /** Filled in by the handler once it knows the account's settings. */ + meta: TimerMeta + timings: RequestTimings + totalMs: () => number + track: (step: string, promise: Promise) => Promise +} + +export const DEFAULT_SLOW_THRESHOLD_MS = 1000 + +/** + * Collects per-step durations in memory with negligible overhead (two + * Date.now() calls per step) so they can be emitted as a single structured log + * line at the end of the request. Deliberately does not log per step: this runs + * on every session transform, so one line per call is the only viable volume. + */ +export const createTimer = (): Timer => { + const startedAt = Date.now() + const timings: RequestTimings = {} + + const track = async (step: string, promise: Promise): Promise => { + const stepStartedAt = Date.now() + + try { + return await promise + } finally { + timings[step] = Date.now() - stepStartedAt + } + } + + return { + meta: {}, + timings, + totalMs: () => Date.now() - startedAt, + track, + } +} + +/** + * Lets the surrounding middleware own the timer while the handler still records + * into it, so timings are emitted even when the handler throws before reaching + * its final statement. Keyed weakly by the request context, so entries disappear + * with the request. + */ +const timers = new WeakMap() + +export const attachTimer = (ctx: object, timer: Timer) => { + timers.set(ctx, timer) +} + +export const getTimer = (ctx: object): Timer | undefined => timers.get(ctx) + +export interface LogRequestTimingsArgs { + extra?: Record + logger: Logger + message: string + /** 0..1 fraction of non-slow requests to log, for baseline visibility. */ + sampleRate?: number + slowThresholdMs?: number + timer: Timer +} + +/** + * Emits the collected timings, but only when the request was slow (logged as + * `warn`) or when it falls into the sample (logged as `info`). This keeps the + * signal useful for diagnosing any account without flooding the log pipeline. + */ +export const logRequestTimings = ({ + extra, + logger, + message, + sampleRate, + slowThresholdMs, + timer, +}: LogRequestTimingsArgs) => { + const totalMs = timer.totalMs() + const threshold = slowThresholdMs ?? DEFAULT_SLOW_THRESHOLD_MS + const isSlow = totalMs >= threshold + + if (!isSlow && !(Math.random() < (sampleRate ?? 0))) { + return + } + + const steps = Object.keys(timer.timings) + + const slowestStep = steps.reduce( + (slowest, step) => + timer.timings[step] > (timer.timings[slowest] ?? -1) ? step : slowest, + steps[0] ?? '' + ) + + const payload = { + message, + slowestStep, + slowestStepMs: timer.timings[slowestStep] ?? 0, + timings: timer.timings, + totalMs, + ...extra, + } + + if (isSlow) { + logger.warn(payload) + } else { + logger.info(payload) + } +} diff --git a/node/utils/staleFromVBaseWhileRevalidate.ts b/node/utils/staleFromVBaseWhileRevalidate.ts new file mode 100644 index 00000000..81cef0e0 --- /dev/null +++ b/node/utils/staleFromVBaseWhileRevalidate.ts @@ -0,0 +1,147 @@ +/* eslint-disable max-params */ +import { createHash } from 'crypto' + +import type { VBase } from '@vtex/api' +import type { Logger } from '@vtex/api/lib/service/logger/logger' + +import type { StaleRevalidateData } from '../typings/staleFromVBaseWhileRevalidate' + +const DEFAULT_EXPIRATION_IN_MINUTES = 30 + +export interface StaleFromVBaseOptions { + expirationInMinutes?: number + /** + * Failures here are recoverable by design (a failed read falls back to the + * origin, a failed background refresh keeps serving the last value), which is + * exactly why they must be logged: without it a broken origin or a VBase + * outage keeps looking perfectly healthy. + */ + logger?: Logger +} + +const getTTL = (expirationInMinutes?: number) => { + const ttl = new Date() + + ttl.setMinutes( + ttl.getMinutes() + (expirationInMinutes ?? DEFAULT_EXPIRATION_IN_MINUTES) + ) + + return ttl +} + +/** + * VBase keys have a restricted charset, so hash the logical key to keep + * callers free to use any descriptive string. + */ +const normalizedJSONFile = (filePath: string) => + `${createHash('md5').update(filePath).digest('hex')}.json` + +const revalidate = async ( + vbase: VBase, + bucket: string, + filePath: string, + key: string, + endDate: Date, + validateFunction: (params?: any) => Promise, + params?: unknown, + logger?: Logger +): Promise => { + const data = await validateFunction(params) + + // Never block the caller on the cache write. + vbase + .saveJSON>(bucket, filePath, { + data, + ttl: endDate, + }) + .catch((error) => { + logger?.error({ + bucket, + error, + key, + message: 'staleFromVBase.saveError', + }) + }) + + return data +} + +/** + * Cross-pod cache backed by VBase, with stale-while-revalidate semantics. + * + * Unlike an in-memory LRU (which every pod has to warm up independently, so + * each cold pod pays the full upstream cost), VBase is shared storage: the + * first pod to populate it warms the cache for all of them. + * + * - No entry: fetch upstream, store, return (blocking, only once per TTL). + * - Fresh entry: return it (one VBase read). + * - Stale entry: return the stale value immediately and refresh in the + * background, so a slow upstream never lands on the request path. + */ +export const staleFromVBaseWhileRevalidate = async ( + vbase: VBase, + bucket: string, + filePath: string, + validateFunction: (params?: any) => Promise, + params?: unknown, + options?: StaleFromVBaseOptions +): Promise => { + const logger = options?.logger + const normalizedFilePath = normalizedJSONFile(filePath) + + const cachedData = (await vbase + .getJSON>(bucket, normalizedFilePath, true) + .catch((error) => { + // Recoverable (the origin is called instead), but a VBase outage must + // still be visible somewhere. + logger?.warn({ + bucket, + error, + key: filePath, + message: 'staleFromVBase.readError', + }) + + return null + })) as StaleRevalidateData | null + + if (!cachedData) { + return revalidate( + vbase, + bucket, + normalizedFilePath, + filePath, + getTTL(options?.expirationInMinutes), + validateFunction, + params, + logger + ) + } + + const { data, ttl } = cachedData + + if (new Date() < new Date(ttl)) { + return data + } + + revalidate( + vbase, + bucket, + normalizedFilePath, + filePath, + getTTL(options?.expirationInMinutes), + validateFunction, + params, + logger + ).catch((error) => { + // The stale value keeps being served, so without this log a failing origin + // would go completely unnoticed. + logger?.error({ + bucket, + error, + key: filePath, + message: 'staleFromVBase.revalidateError', + }) + }) + + return data +} diff --git a/node/yarn.lock b/node/yarn.lock index f4d088dc..e94a8cdb 100644 --- a/node/yarn.lock +++ b/node/yarn.lock @@ -9,11 +9,118 @@ dependencies: "@babel/highlight" "^7.14.5" +"@babel/code-frame@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" + integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== + +"@babel/core@^7.1.0", "@babel/core@^7.12.3", "@babel/core@^7.7.5": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" + integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-compilation-targets" "^7.29.7" + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helpers" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.29.7", "@babel/generator@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.8.tgz#4b0b887885422643339e09022148a4c4ebaa4979" + integrity sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg== + dependencies: + "@babel/parser" "^7.29.8" + "@babel/types" "^7.29.8" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042" + integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g== + dependencies: + "@babel/compat-data" "^7.29.7" + "@babel/helper-validator-option" "^7.29.7" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b" + integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA== + +"@babel/helper-module-imports@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396" + integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g== + dependencies: + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/helper-module-transforms@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae" + integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg== + dependencies: + "@babel/helper-module-imports" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@babel/traverse" "^7.29.7" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.29.7", "@babel/helper-plugin-utils@^7.8.0": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz#c0a0766f1a13617d8a17407d7ab8f9d486225ea4" + integrity sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw== + +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== + "@babel/helper-validator-identifier@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz#d0f0e277c512e0c938277faa85a3968c9a44c0e8" integrity sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@babel/helper-validator-option@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a" + integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw== + +"@babel/helpers@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607" + integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg== + dependencies: + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/highlight@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9" @@ -23,6 +130,161 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.29.7", "@babel/parser@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.8.tgz#9653716a2f10c677b98fbc63d4bfb000c302cf17" + integrity sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA== + dependencies: + "@babel/types" "^7.29.8" + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" + integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-import-attributes@^7.24.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz#6115264516e95ead0f35a41710906612e447f605" + integrity sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg== + dependencies: + "@babel/helper-plugin-utils" "^7.29.7" + +"@babel/plugin-syntax-import-meta@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/template@^7.29.7", "@babel/template@^7.3.3": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" + integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + +"@babel/traverse@^7.1.0", "@babel/traverse@^7.29.7": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.8.tgz#4111014cdc71a0f95d9471907590baa0b8a6b28a" + integrity sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.8" + "@babel/helper-globals" "^7.29.7" + "@babel/parser" "^7.29.8" + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.8" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.28.2", "@babel/types@^7.29.7", "@babel/types@^7.29.8", "@babel/types@^7.3.3": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863" + integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@cnakazawa/watch@^1.0.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a" + integrity sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ== + dependencies: + exec-sh "^0.3.2" + minimist "^1.2.0" + "@grpc/grpc-js@^1.7.1": version "1.14.3" resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.3.tgz#4c9b817a900ae4020ddc28515ae4b52c78cfb8da" @@ -41,6 +303,227 @@ protobufjs "^7.5.3" yargs "^17.7.2" +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" + integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + get-package-type "^0.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.6.tgz#8dc9afa2ac1506cb1a58f89940f1c124446c8df3" + integrity sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw== + +"@jest/console@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-26.6.2.tgz#4e04bc464014358b03ab4937805ee36a0aeb98f2" + integrity sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g== + dependencies: + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + jest-message-util "^26.6.2" + jest-util "^26.6.2" + slash "^3.0.0" + +"@jest/core@^26.6.3": + version "26.6.3" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-26.6.3.tgz#7639fcb3833d748a4656ada54bde193051e45fad" + integrity sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw== + dependencies: + "@jest/console" "^26.6.2" + "@jest/reporters" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.4" + jest-changed-files "^26.6.2" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" + jest-regex-util "^26.0.0" + jest-resolve "^26.6.2" + jest-resolve-dependencies "^26.6.3" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" + jest-watcher "^26.6.2" + micromatch "^4.0.2" + p-each-series "^2.1.0" + rimraf "^3.0.0" + slash "^3.0.0" + strip-ansi "^6.0.0" + +"@jest/environment@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-26.6.2.tgz#ba364cc72e221e79cc8f0a99555bf5d7577cf92c" + integrity sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA== + dependencies: + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + jest-mock "^26.6.2" + +"@jest/fake-timers@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-26.6.2.tgz#459c329bcf70cee4af4d7e3f3e67848123535aad" + integrity sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA== + dependencies: + "@jest/types" "^26.6.2" + "@sinonjs/fake-timers" "^6.0.1" + "@types/node" "*" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" + jest-util "^26.6.2" + +"@jest/globals@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-26.6.2.tgz#5b613b78a1aa2655ae908eba638cc96a20df720a" + integrity sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA== + dependencies: + "@jest/environment" "^26.6.2" + "@jest/types" "^26.6.2" + expect "^26.6.2" + +"@jest/reporters@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-26.6.2.tgz#1f518b99637a5f18307bd3ecf9275f6882a667f6" + integrity sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" + chalk "^4.0.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.2" + graceful-fs "^4.2.4" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^4.0.3" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.0.2" + jest-haste-map "^26.6.2" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" + slash "^3.0.0" + source-map "^0.6.0" + string-length "^4.0.1" + terminal-link "^2.0.0" + v8-to-istanbul "^7.0.0" + optionalDependencies: + node-notifier "^8.0.0" + +"@jest/source-map@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-26.6.2.tgz#29af5e1e2e324cafccc936f218309f54ab69d535" + integrity sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA== + dependencies: + callsites "^3.0.0" + graceful-fs "^4.2.4" + source-map "^0.6.0" + +"@jest/test-result@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-26.6.2.tgz#55da58b62df134576cc95476efa5f7949e3f5f18" + integrity sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ== + dependencies: + "@jest/console" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/istanbul-lib-coverage" "^2.0.0" + collect-v8-coverage "^1.0.0" + +"@jest/test-sequencer@^26.6.3": + version "26.6.3" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz#98e8a45100863886d074205e8ffdc5a7eb582b17" + integrity sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw== + dependencies: + "@jest/test-result" "^26.6.2" + graceful-fs "^4.2.4" + jest-haste-map "^26.6.2" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" + +"@jest/transform@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-26.6.2.tgz#5ac57c5fa1ad17b2aae83e73e45813894dcf2e4b" + integrity sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA== + dependencies: + "@babel/core" "^7.1.0" + "@jest/types" "^26.6.2" + babel-plugin-istanbul "^6.0.0" + chalk "^4.0.0" + convert-source-map "^1.4.0" + fast-json-stable-stringify "^2.0.0" + graceful-fs "^4.2.4" + jest-haste-map "^26.6.2" + jest-regex-util "^26.0.0" + jest-util "^26.6.2" + micromatch "^4.0.2" + pirates "^4.0.1" + slash "^3.0.0" + source-map "^0.6.1" + write-file-atomic "^3.0.0" + +"@jest/types@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" + integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^15.0.0" + chalk "^4.0.0" + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@js-sdsl/ordered-map@^4.4.2": version "4.4.2" resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" @@ -424,6 +907,25 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@sinonjs/commons@^1.7.0": + version "1.8.6" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.6.tgz#80c516a4dc264c2a69115e7578d62581ff455ed9" + integrity sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40" + integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@tootallnate/once@1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" + integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== + "@types/accepts@*": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" @@ -436,6 +938,39 @@ resolved "https://registry.yarnpkg.com/@types/atob/-/atob-2.1.2.tgz#157eb0cc46264a8c55f2273a836c7a1a644fb820" integrity sha512-8GAYQ1jDRUQkSpHzJUqXwAkYFOxuWAOGLhIR4aPd/Y/yL12Q/9m7LsKpHKlfKdNE/362Hc9wPI1Yh6opDfxVJg== +"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.4", "@types/babel__traverse@^7.0.6": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + "@types/bluebird@^3.5.25": version "3.5.35" resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.35.tgz#3964c48372bf62d60616d8673dd77a9719ebac9b" @@ -504,6 +1039,13 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/graceful-fs@^4.1.2": + version "4.1.9" + resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" + integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== + dependencies: + "@types/node" "*" + "@types/graphql@^14.5.0": version "14.5.0" resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-14.5.0.tgz#a545fb3bc8013a3547cf2f07f5e13a33642b75d6" @@ -521,6 +1063,33 @@ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.0.tgz#682477dbbbd07cd032731cb3b0e7eaee3d026b69" integrity sha512-2aoSC4UUbHDj2uCsCxcG/vRMXey/m17bC7UwitVm5hn22nI8O8Y9iDpA76Orc+DWkQ4zZrOKEshCqR/jSuXAHA== +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + +"@types/istanbul-lib-report@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz#53047614ae72e19fc0401d872de3ae2b4ce350bf" + integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" + integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/jest@^26.0.24": + version "26.0.24" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.24.tgz#943d11976b16739185913a1936e0de0c4a7d595a" + integrity sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w== + dependencies: + jest-diff "^26.0.0" + pretty-format "^26.0.0" + "@types/jsonwebtoken@^8.5.0": version "8.5.1" resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#56958cb2d80f6d74352bd2e501a018e2506a8a84" @@ -576,6 +1145,16 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.15.tgz#10ee6a6a3f971966fddfa3f6e89ef7a73ec622df" integrity sha512-F6S4Chv4JicJmyrwlDkxUdGNSplsQdGwp1A0AJloEVDirWdZOAiRHhovDlsFkKUrquUXhz1imJhXHsf59auyAg== +"@types/normalize-package-data@^2.4.0": + version "2.4.4" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" + integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== + +"@types/prettier@^2.0.0": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f" + integrity sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA== + "@types/qs@*": version "6.9.6" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1" @@ -603,10 +1182,27 @@ resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded" integrity sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg== -"@vtex/api@6.50.1": - version "6.50.1" - resolved "https://registry.yarnpkg.com/@vtex/api/-/api-6.50.1.tgz#a86578982a7aac7c7a8df2b9ec3df18bc43f01f2" - integrity sha512-4IlmYwCXKpkdpN2KN6NkPuRwnjet3ilSoET3PBOTTdZqE/mnuvIxRZRaQy+Yp7Gxu0XKAVdp/8SQJhsJaa6Unw== +"@types/stack-utils@^2.0.0": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" + integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== + +"@types/yargs-parser@*": + version "21.0.3" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" + integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== + +"@types/yargs@^15.0.0": + version "15.0.20" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.20.tgz#6d00a124c9f757427d4ca3cbc87daea778053c68" + integrity sha512-KIkX+/GgfFitlASYCGoSF+T4XRXhOubJLhkLVtSfsRTe9jWMmuM2g28zQ41BtPTG7TRBb2xHW+LCNVE9QR/vsg== + dependencies: + "@types/yargs-parser" "*" + +"@vtex/api@6.51.3": + version "6.51.3" + resolved "https://registry.yarnpkg.com/@vtex/api/-/api-6.51.3.tgz#da2b7f223e9abb64f04d8c34b10063224be3e728" + integrity sha512-jN6OKhtU8RGjQEugheJhRJDa4JleW2mzXxoFdgTAY0ecrkl1a3aBnOuo3z5ClBhgzO1cu3HYYBd2v1CeCBM9Vg== dependencies: "@types/koa" "^2.11.0" "@types/koa-compose" "^3.2.3" @@ -704,6 +1300,11 @@ dependencies: tslib "^1.9.3" +abab@^2.0.3, abab@^2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== + accepts@^1.3.5: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" @@ -712,16 +1313,46 @@ accepts@^1.3.5: mime-types "~2.1.24" negotiator "0.6.2" +acorn-globals@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45" + integrity sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg== + dependencies: + acorn "^7.1.1" + acorn-walk "^7.1.1" + acorn-import-attributes@^1.9.5: version "1.9.5" resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== +acorn-walk@^7.1.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" + integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== + +acorn@^7.1.1: + version "7.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + acorn@^8.14.0: version "8.16.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== +acorn@^8.2.4: + version "8.18.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" + integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + agentkeepalive@^4.0.2: version "4.1.4" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.1.4.tgz#d928028a4862cb11718e55227872e842a44c945b" @@ -736,7 +1367,14 @@ ansi-color@^0.2.1: resolved "https://registry.yarnpkg.com/ansi-color/-/ansi-color-0.2.1.tgz#3e75c037475217544ed763a8db5709fa9ae5bf9a" integrity sha1-PnXAN0dSF1RO12Oo21cJ+prlv5o= -ansi-regex@^5.0.1: +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-regex@^5.0.0, ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== @@ -748,7 +1386,7 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" -ansi-styles@^4.0.0: +ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -760,6 +1398,22 @@ any-promise@^1.1.0: resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +anymatch@^3.0.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + apollo-link@^1.2.14: version "1.2.14" resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.14.tgz#3feda4b47f9ebba7f4160bef8b977ba725b684d9" @@ -821,6 +1475,31 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA== + +arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q== + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ== + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw== + async@^2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" @@ -854,6 +1533,70 @@ axios@^0.30.1: form-data "^4.0.4" proxy-from-env "^1.1.0" +babel-jest@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056" + integrity sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA== + dependencies: + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/babel__core" "^7.1.7" + babel-plugin-istanbul "^6.0.0" + babel-preset-jest "^26.6.2" + chalk "^4.0.0" + graceful-fs "^4.2.4" + slash "^3.0.0" + +babel-plugin-istanbul@^6.0.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" + integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-instrument "^5.0.4" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz#8185bd030348d254c6d7dd974355e6a28b21e62d" + integrity sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw== + dependencies: + "@babel/template" "^7.3.3" + "@babel/types" "^7.3.3" + "@types/babel__core" "^7.0.0" + "@types/babel__traverse" "^7.0.6" + +babel-preset-current-node-syntax@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6" + integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-import-attributes" "^7.24.7" + "@babel/plugin-syntax-import-meta" "^7.10.4" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + +babel-preset-jest@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz#747872b1171df032252426586881d62d31798fee" + integrity sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ== + dependencies: + babel-plugin-jest-hoist "^26.6.2" + babel-preset-current-node-syntax "^1.0.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -864,6 +1607,24 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +baseline-browser-mapping@^2.11.12: + version "2.11.15" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz#9c0cac93d7d304f3d61bb41088a102cd62e68676" + integrity sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA== + bintrees@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" @@ -896,11 +1657,69 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +braces@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-process-hrtime@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" + integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== + +browserslist@^4.24.0: + version "4.28.8" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0" + integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA== + dependencies: + baseline-browser-mapping "^2.11.12" + caniuse-lite "^1.0.30001809" + electron-to-chromium "^1.5.402" + node-releases "^2.0.53" + update-browserslist-db "^1.3.0" + +bs-logger@0.x: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + buffer-crc32@^0.2.1, buffer-crc32@^0.2.13: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= +buffer-from@1.x, buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + buffer@^5.1.0, buffer@^5.5.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" @@ -936,6 +1755,21 @@ bytes@3.1.0, bytes@^3.0.0: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + cache-content-type@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" @@ -960,6 +1794,33 @@ call-bind@^1.0.0: function-bind "^1.1.1" get-intrinsic "^1.0.2" +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.0.0, camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001809: + version "1.0.30001809" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz#e6cf71f14ddfe008f114dd2a846923be3c03a07b" + integrity sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ== + +capture-exit@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" + integrity sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g== + dependencies: + rsvp "^4.8.4" + chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -969,16 +1830,58 @@ chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + chownr@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +cjs-module-lexer@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz#4186fcca0eae175970aee870b9fe2d6cf8d5655f" + integrity sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw== + cjs-module-lexer@^1.2.2: version "1.4.3" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz#0f79731eb8cfe1ec72acd4066efac9d61991b00d" integrity sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q== +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -1003,6 +1906,19 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= +collect-v8-coverage@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz#cc1f01eb8d02298cbc9a437c74c70ab4e5210b80" + integrity sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw== + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw== + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -1039,6 +1955,11 @@ commander@^2.12.1, commander@^2.20.3: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +component-emitter@^1.2.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" + integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + compress-commons@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-2.1.1.tgz#9410d9a534cf8435e3fbbb7c6ce48de2dc2f0610" @@ -1073,6 +1994,16 @@ content-type@^1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +convert-source-map@^1.4.0, convert-source-map@^1.6.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + cookie@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" @@ -1086,6 +2017,11 @@ cookies@~0.8.0: depd "~2.0.0" keygrip "~1.1.0" +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw== + core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -1106,16 +2042,76 @@ crc@^3.4.4: dependencies: buffer "^5.1.0" +cross-spawn@^6.0.0: + version "6.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57" + integrity sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +cross-spawn@^7.0.0: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + cssfilter@0.0.10: version "0.0.10" resolved "https://registry.yarnpkg.com/cssfilter/-/cssfilter-0.0.10.tgz#c6d2672632a2e5c83e013e6864a42ce8defd20ae" integrity sha1-xtJnJjKi5cg+AT5oZKQs6N79IK4= +cssom@^0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" + integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== + +cssom@~0.3.6: + version "0.3.8" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" + integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== + +cssstyle@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" + integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== + dependencies: + cssom "~0.3.6" + +data-urls@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" + integrity sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ== + dependencies: + abab "^2.0.3" + whatwg-mimetype "^2.3.0" + whatwg-url "^8.0.0" + dataloader@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-1.4.0.tgz#bca11d867f5d3f1b9ed9f737bd15970c65dff5c8" integrity sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw== +debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.5: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + debug@^3.1.0: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -1130,13 +2126,6 @@ debug@^4.1.0: dependencies: ms "2.1.2" -debug@^4.3.5: - version "4.4.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - debug@~3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" @@ -1144,11 +2133,53 @@ debug@~3.1.0: dependencies: ms "2.0.0" +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + +decimal.js@^10.2.1: + version "10.6.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" + integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== + +decode-uri-component@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" + integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== + deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= +deepmerge@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA== + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA== + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -1179,6 +2210,11 @@ destroy@^1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= +detect-newline@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" + integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== + dicer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.3.0.tgz#eacd98b3bfbf92e8ab5c2fdb71aaac44bb06b872" @@ -1186,6 +2222,11 @@ dicer@0.3.0: dependencies: streamsearch "0.1.2" +diff-sequences@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" + integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -1199,6 +2240,13 @@ doctrine@^0.7.2: esutils "^1.1.6" isarray "0.0.1" +domexception@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" + integrity sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg== + dependencies: + webidl-conversions "^5.0.0" + dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" @@ -1213,6 +2261,16 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= +electron-to-chromium@^1.5.402: + version "1.5.411" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz#d56c7e43f91e2b3abb53b4eecd646e20d0b18b34" + integrity sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg== + +emittery@^0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.2.tgz#25595908e13af0f5674ab419396e2fb394cdfa82" + integrity sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -1230,6 +2288,13 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" +error-ex@^1.3.1: + version "1.3.4" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== + dependencies: + is-arrayish "^0.2.1" + error@7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02" @@ -1272,7 +2337,7 @@ es-set-tostringtag@^2.1.0: has-tostringtag "^1.0.2" hasown "^2.0.2" -escalade@^3.1.1: +escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== @@ -1287,26 +2352,192 @@ escape-string-regexp@^1.0.5: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -esprima@^4.0.0: +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escodegen@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" + integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionalDependencies: + source-map "~0.6.1" + +esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== +estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + esutils@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/esutils/-/esutils-1.1.6.tgz#c01ccaa9ae4b897c6d0c3e210ae52f3c7a844375" integrity sha1-wBzKqa5LiXxtDD4hCuUvPHqEQ3U= -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -follow-redirects@^1.15.4: +exec-sh@^0.3.2: + version "0.3.6" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" + integrity sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w== + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +execa@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" + integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA== + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +expect@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/expect/-/expect-26.6.2.tgz#c6b996bf26bf3fe18b67b2d0f51fc981ba934417" + integrity sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA== + dependencies: + "@jest/types" "^26.6.2" + ansi-styles "^4.0.0" + jest-get-type "^26.3.0" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-regex-util "^26.0.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug== + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q== + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fb-watchman@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" + integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== + dependencies: + bser "2.1.1" + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ== + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +follow-redirects@^1.15.4: version "1.15.11" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== +for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ== + +form-data@^3.0.0: + version "3.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.5.tgz#2ea3ec24f0dcb7e0262a11efb732031240ea8e9f" + integrity sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + form-data@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" @@ -1323,6 +2554,13 @@ forwarded-parse@2.1.2: resolved "https://registry.yarnpkg.com/forwarded-parse/-/forwarded-parse-2.1.2.tgz#08511eddaaa2ddfd56ba11138eee7df117a09325" integrity sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw== +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA== + dependencies: + map-cache "^0.2.2" + fresh@~0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -1352,6 +2590,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@^2.1.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -1362,7 +2605,12 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -get-caller-file@^2.0.5: +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== @@ -1392,6 +2640,11 @@ get-intrinsic@^1.2.6: hasown "^2.0.2" math-intrinsics "^1.1.0" +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" @@ -1400,6 +2653,25 @@ get-proto@^1.0.1: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-stream@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA== + glob@^7.1.1, glob@^7.1.4: version "7.1.7" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" @@ -1412,6 +2684,18 @@ glob@^7.1.1, glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.1.2, glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + gopd@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" @@ -1422,6 +2706,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== +graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + graphql-tools@^4.0.6: version "4.0.8" resolved "https://registry.yarnpkg.com/graphql-tools/-/graphql-tools-4.0.8.tgz#e7fb9f0d43408fb0878ba66b522ce871bafe9d30" @@ -1455,11 +2744,21 @@ graphql@^14.5.0, graphql@^14.5.8: dependencies: iterall "^1.2.2" +growly@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" + integrity sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw== + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-symbols@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" @@ -1477,6 +2776,37 @@ has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q== + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw== + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ== + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ== + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -1484,6 +2814,13 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hasown@^2.0.0, hasown@^2.0.3, hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + hasown@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" @@ -1501,6 +2838,23 @@ hexer@^1.5.0: process "^0.10.0" xtend "^4.0.0" +hosted-git-info@^2.1.4: + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== + +html-encoding-sniffer@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" + integrity sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ== + dependencies: + whatwg-encoding "^1.0.5" + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + http-assert@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.4.1.tgz#c5f725d677aa7e873ef736199b89686cceb37878" @@ -1542,6 +2896,28 @@ http-errors@^1.8.1: statuses ">= 1.5.0 < 2" toidentifier "1.0.1" +http-proxy-agent@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" + integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== + dependencies: + "@tootallnate/once" "1" + agent-base "6" + debug "4" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +human-signals@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" + integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" @@ -1571,6 +2947,19 @@ import-in-the-middle@^1.8.1: cjs-module-lexer "^1.2.2" module-details-from-path "^1.0.3" +import-local@^3.0.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" + integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + inflation@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f" @@ -1589,6 +2978,30 @@ inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +is-accessor-descriptor@^1.0.1, is-accessor-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.2.tgz#fff67ca43f7acc57edbc557335c2e2c62b98e41e" + integrity sha512-AIbwAcazqP3R65dGvqk1V+a+vE5Fg1yu/ZKMOiBWSUIXXiwQkYmXQcVa2O0nh0tSDKDFKxG2mY7dB1Sr4hEP1g== + dependencies: + hasown "^2.0.3" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + is-core-module@^2.16.1: version "2.16.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" @@ -1603,41 +3016,201 @@ is-core-module@^2.2.0: dependencies: has "^1.0.3" +is-data-descriptor@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz#2109164426166d32ea38c405c1e0945d9e6a4eeb" + integrity sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw== + dependencies: + hasown "^2.0.0" + +is-descriptor@^0.1.0: + version "0.1.8" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.8.tgz#c1ea9d2fb20cd51e812e438a455a7f78443a5a17" + integrity sha512-SceYGWXvdqlWa/OnQ5FQuV+NxvNmMRhMw/w9AHkH71hTzveND4BTYgvp16g+oITK47qbOl/3D0bl0iygehWAWQ== + dependencies: + is-accessor-descriptor "^1.0.1" + is-data-descriptor "^1.0.1" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.4.tgz#7934d74f609d0dbe754b80186d501026a78739b5" + integrity sha512-bv5z95W0dDtLfKwDfkTNxaRxmISBD3eQBKJeVxv2AQ7MjuUnDNG7cIQqvFtMOUYhsILWHhMayWdoGqNqYYYjww== + dependencies: + is-accessor-descriptor "^1.0.2" + is-data-descriptor "^1.0.1" + +is-docker@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-generator-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + is-generator-function@^1.0.7: version "1.0.9" resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.9.tgz#e5f82c2323673e7fcad3d12858c83c4039f6399c" integrity sha512-ZJ34p1uvIfptHCN7sFTjGibB9/oBg17sHqzDLfuwhvmN/qLVvIQXRQ8licZQ35WJ8KuEQt/etnnzQFI9C9Ue/A== +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg== + dependencies: + kind-of "^3.0.2" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + is-retry-allowed@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg== +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== + is-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== +is-typedarray@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= -isarray@~1.0.0: +isarray@1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + isnumber@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isnumber/-/isnumber-1.0.0.tgz#0e3f9759b581d99dd85086f0ec2a74909cfadd01" integrity sha1-Dj+XWbWB2Z3YUIbw7Cp0kJz63QE= +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA== + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-instrument@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz#873c6fff897450118222774696a3f28902d77c1d" + integrity sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== + dependencies: + "@babel/core" "^7.7.5" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.0.0" + semver "^6.3.0" + +istanbul-lib-instrument@^5.0.4: + version "5.2.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" + integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== + dependencies: + "@babel/core" "^7.12.3" + "@babel/parser" "^7.14.7" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.2.0" + semver "^6.3.0" + +istanbul-lib-report@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" + integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + +istanbul-reports@^3.0.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + iterall@^1.1.3, iterall@^1.2.2: version "1.3.0" resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" @@ -1654,6 +3227,379 @@ jaeger-client@^3.18.0: uuid "^8.3.2" xorshift "^1.1.1" +jest-changed-files@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.6.2.tgz#f6198479e1cc66f22f9ae1e22acaa0b429c042d0" + integrity sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ== + dependencies: + "@jest/types" "^26.6.2" + execa "^4.0.0" + throat "^5.0.0" + +jest-cli@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-26.6.3.tgz#43117cfef24bc4cd691a174a8796a532e135e92a" + integrity sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg== + dependencies: + "@jest/core" "^26.6.3" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.4" + import-local "^3.0.2" + is-ci "^2.0.0" + jest-config "^26.6.3" + jest-util "^26.6.2" + jest-validate "^26.6.2" + prompts "^2.0.1" + yargs "^15.4.1" + +jest-config@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-26.6.3.tgz#64f41444eef9eb03dc51d5c53b75c8c71f645349" + integrity sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg== + dependencies: + "@babel/core" "^7.1.0" + "@jest/test-sequencer" "^26.6.3" + "@jest/types" "^26.6.2" + babel-jest "^26.6.3" + chalk "^4.0.0" + deepmerge "^4.2.2" + glob "^7.1.1" + graceful-fs "^4.2.4" + jest-environment-jsdom "^26.6.2" + jest-environment-node "^26.6.2" + jest-get-type "^26.3.0" + jest-jasmine2 "^26.6.3" + jest-regex-util "^26.0.0" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" + micromatch "^4.0.2" + pretty-format "^26.6.2" + +jest-diff@^26.0.0, jest-diff@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" + integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== + dependencies: + chalk "^4.0.0" + diff-sequences "^26.6.2" + jest-get-type "^26.3.0" + pretty-format "^26.6.2" + +jest-docblock@^26.0.0: + version "26.0.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" + integrity sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w== + dependencies: + detect-newline "^3.0.0" + +jest-each@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-26.6.2.tgz#02526438a77a67401c8a6382dfe5999952c167cb" + integrity sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A== + dependencies: + "@jest/types" "^26.6.2" + chalk "^4.0.0" + jest-get-type "^26.3.0" + jest-util "^26.6.2" + pretty-format "^26.6.2" + +jest-environment-jsdom@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz#78d09fe9cf019a357009b9b7e1f101d23bd1da3e" + integrity sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q== + dependencies: + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + jest-mock "^26.6.2" + jest-util "^26.6.2" + jsdom "^16.4.0" + +jest-environment-node@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-26.6.2.tgz#824e4c7fb4944646356f11ac75b229b0035f2b0c" + integrity sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag== + dependencies: + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + jest-mock "^26.6.2" + jest-util "^26.6.2" + +jest-get-type@^26.3.0: + version "26.3.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" + integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== + +jest-haste-map@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" + integrity sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w== + dependencies: + "@jest/types" "^26.6.2" + "@types/graceful-fs" "^4.1.2" + "@types/node" "*" + anymatch "^3.0.3" + fb-watchman "^2.0.0" + graceful-fs "^4.2.4" + jest-regex-util "^26.0.0" + jest-serializer "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" + micromatch "^4.0.2" + sane "^4.0.3" + walker "^1.0.7" + optionalDependencies: + fsevents "^2.1.2" + +jest-jasmine2@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz#adc3cf915deacb5212c93b9f3547cd12958f2edd" + integrity sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg== + dependencies: + "@babel/traverse" "^7.1.0" + "@jest/environment" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + co "^4.6.0" + expect "^26.6.2" + is-generator-fn "^2.0.0" + jest-each "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + pretty-format "^26.6.2" + throat "^5.0.0" + +jest-leak-detector@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz#7717cf118b92238f2eba65054c8a0c9c653a91af" + integrity sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg== + dependencies: + jest-get-type "^26.3.0" + pretty-format "^26.6.2" + +jest-matcher-utils@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz#8e6fd6e863c8b2d31ac6472eeb237bc595e53e7a" + integrity sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw== + dependencies: + chalk "^4.0.0" + jest-diff "^26.6.2" + jest-get-type "^26.3.0" + pretty-format "^26.6.2" + +jest-message-util@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.6.2.tgz#58173744ad6fc0506b5d21150b9be56ef001ca07" + integrity sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA== + dependencies: + "@babel/code-frame" "^7.0.0" + "@jest/types" "^26.6.2" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.4" + micromatch "^4.0.2" + pretty-format "^26.6.2" + slash "^3.0.0" + stack-utils "^2.0.2" + +jest-mock@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.6.2.tgz#d6cb712b041ed47fe0d9b6fc3474bc6543feb302" + integrity sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew== + dependencies: + "@jest/types" "^26.6.2" + "@types/node" "*" + +jest-pnp-resolver@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" + integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== + +jest-regex-util@^26.0.0: + version "26.0.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28" + integrity sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A== + +jest-resolve-dependencies@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz#6680859ee5d22ee5dcd961fe4871f59f4c784fb6" + integrity sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg== + dependencies: + "@jest/types" "^26.6.2" + jest-regex-util "^26.0.0" + jest-snapshot "^26.6.2" + +jest-resolve@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-26.6.2.tgz#a3ab1517217f469b504f1b56603c5bb541fbb507" + integrity sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ== + dependencies: + "@jest/types" "^26.6.2" + chalk "^4.0.0" + graceful-fs "^4.2.4" + jest-pnp-resolver "^1.2.2" + jest-util "^26.6.2" + read-pkg-up "^7.0.1" + resolve "^1.18.1" + slash "^3.0.0" + +jest-runner@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-26.6.3.tgz#2d1fed3d46e10f233fd1dbd3bfaa3fe8924be159" + integrity sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ== + dependencies: + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + emittery "^0.7.1" + exit "^0.1.2" + graceful-fs "^4.2.4" + jest-config "^26.6.3" + jest-docblock "^26.0.0" + jest-haste-map "^26.6.2" + jest-leak-detector "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" + jest-runtime "^26.6.3" + jest-util "^26.6.2" + jest-worker "^26.6.2" + source-map-support "^0.5.6" + throat "^5.0.0" + +jest-runtime@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-26.6.3.tgz#4f64efbcfac398331b74b4b3c82d27d401b8fa2b" + integrity sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw== + dependencies: + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/globals" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/yargs" "^15.0.0" + chalk "^4.0.0" + cjs-module-lexer "^0.6.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.3" + graceful-fs "^4.2.4" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" + jest-regex-util "^26.0.0" + jest-resolve "^26.6.2" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" + slash "^3.0.0" + strip-bom "^4.0.0" + yargs "^15.4.1" + +jest-serializer@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-26.6.2.tgz#d139aafd46957d3a448f3a6cdabe2919ba0742d1" + integrity sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g== + dependencies: + "@types/node" "*" + graceful-fs "^4.2.4" + +jest-snapshot@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-26.6.2.tgz#f3b0af1acb223316850bd14e1beea9837fb39c84" + integrity sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og== + dependencies: + "@babel/types" "^7.0.0" + "@jest/types" "^26.6.2" + "@types/babel__traverse" "^7.0.4" + "@types/prettier" "^2.0.0" + chalk "^4.0.0" + expect "^26.6.2" + graceful-fs "^4.2.4" + jest-diff "^26.6.2" + jest-get-type "^26.3.0" + jest-haste-map "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" + natural-compare "^1.4.0" + pretty-format "^26.6.2" + semver "^7.3.2" + +jest-util@^26.1.0, jest-util@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz#907535dbe4d5a6cb4c47ac9b926f6af29576cbc1" + integrity sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q== + dependencies: + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + graceful-fs "^4.2.4" + is-ci "^2.0.0" + micromatch "^4.0.2" + +jest-validate@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-26.6.2.tgz#23d380971587150467342911c3d7b4ac57ab20ec" + integrity sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ== + dependencies: + "@jest/types" "^26.6.2" + camelcase "^6.0.0" + chalk "^4.0.0" + jest-get-type "^26.3.0" + leven "^3.1.0" + pretty-format "^26.6.2" + +jest-watcher@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-26.6.2.tgz#a5b683b8f9d68dbcb1d7dae32172d2cca0592975" + integrity sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ== + dependencies: + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + jest-util "^26.6.2" + string-length "^4.0.1" + +jest-worker@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" + integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^7.0.0" + +jest@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest/-/jest-26.6.3.tgz#40e8fdbe48f00dfa1f0ce8121ca74b88ac9148ef" + integrity sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q== + dependencies: + "@jest/core" "^26.6.3" + import-local "^3.0.2" + jest-cli "^26.6.3" + js-base64@^2.5.1: version "2.6.4" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.6.4.tgz#f4e686c5de1ea1f867dbcad3d46d969428df98c4" @@ -1672,6 +3618,54 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +jsdom@^16.4.0: + version "16.7.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" + integrity sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw== + dependencies: + abab "^2.0.5" + acorn "^8.2.4" + acorn-globals "^6.0.0" + cssom "^0.4.4" + cssstyle "^2.3.0" + data-urls "^2.0.0" + decimal.js "^10.2.1" + domexception "^2.0.1" + escodegen "^2.0.0" + form-data "^3.0.0" + html-encoding-sniffer "^2.0.1" + http-proxy-agent "^4.0.1" + https-proxy-agent "^5.0.0" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.0" + parse5 "6.0.1" + saxes "^5.0.1" + symbol-tree "^3.2.4" + tough-cookie "^4.0.0" + w3c-hr-time "^1.0.2" + w3c-xmlserializer "^2.0.0" + webidl-conversions "^6.1.0" + whatwg-encoding "^1.0.5" + whatwg-mimetype "^2.3.0" + whatwg-url "^8.5.0" + ws "^7.4.6" + xml-name-validator "^3.0.0" + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json5@2.x, json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" @@ -1686,6 +3680,30 @@ keygrip@~1.1.0: dependencies: tsscmp "1.0.6" +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw== + dependencies: + is-buffer "^1.1.5" + +kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + koa-compose@^3.0.0: version "3.2.1" resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-3.2.1.tgz#a85ccb40b7d986d8e5a345b3a1ace8eabcf54de7" @@ -1769,6 +3787,23 @@ lazystream@^1.0.0: dependencies: readable-stream "^2.0.5" +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" @@ -1799,6 +3834,11 @@ lodash.union@^4.6.0: resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg= +lodash@4.x, lodash@^4.7.0: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + lodash@^4.17.14: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -1821,6 +3861,37 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +make-error@1.x: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +makeerror@1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" + integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== + dependencies: + tmpl "1.0.5" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg== + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w== + dependencies: + object-visit "^1.0.0" + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -1831,16 +3902,53 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + methods@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= +micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +micromatch@^4.0.2: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + mime-db@1.48.0, "mime-db@>= 1.43.0 < 2": version "1.48.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + mime-types@^2.1.12, mime-types@^2.1.18, mime-types@~2.1.24: version "2.1.31" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.31.tgz#a00d76b74317c61f9c2db2218b8e9f8e9c5c9e6b" @@ -1848,6 +3956,18 @@ mime-types@^2.1.12, mime-types@^2.1.18, mime-types@~2.1.24: dependencies: mime-db "1.48.0" +mime-types@^2.1.35: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -1855,16 +3975,41 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" +minimatch@^3.1.1: + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== + dependencies: + brace-expansion "^1.1.7" + minimist@^1.1.0, minimist@^1.2.5: version "1.2.7" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minimist@^1.1.1, minimist@^1.2.0: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mixin-deep@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + mkdirp-classic@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== +mkdirp@1.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + mkdirp@^0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" @@ -1892,31 +4037,134 @@ ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= +node-notifier@^8.0.0: + version "8.0.2" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-8.0.2.tgz#f3167a38ef0d2c8a866a83e318c1ba0efeb702c5" + integrity sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg== + dependencies: + growly "^1.3.0" + is-wsl "^2.2.0" + semver "^7.3.2" + shellwords "^0.1.1" + uuid "^8.3.0" + which "^2.0.2" + +node-releases@2.0.14, node-releases@^2.0.53: + version "2.0.14" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" + integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== + +normalize-package-data@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w== + dependencies: + remove-trailing-separator "^1.0.1" + normalize-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -object-inspect@^1.9.0: - version "1.10.3" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369" - integrity sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw== - +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw== + dependencies: + path-key "^2.0.0" + +npm-run-path@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +nwsapi@^2.2.0: + version "2.2.24" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.24.tgz#f8927043d4c9b516abdebe804a32c8d1f9484d1f" + integrity sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A== + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ== + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-inspect@^1.9.0: + version "1.10.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369" + integrity sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw== + object-path@^0.11.8: version "0.11.8" resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.8.tgz#ed002c02bbdd0070b78a27455e8ae01fc14d4742" integrity sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA== +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA== + dependencies: + isobject "^3.0.0" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ== + dependencies: + isobject "^3.0.1" + on-finished@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -1931,6 +4179,13 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + only@~0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" @@ -1941,6 +4196,16 @@ opentracing@^0.14.4: resolved "https://registry.yarnpkg.com/opentracing/-/opentracing-0.14.5.tgz#891fa92cd90a24e64f99bc964370227310926c85" integrity sha512-XLKtEfHxqrWyF1fzxznsv78w3csW41ucHnjiKnfzZLD5FN8UBDZZL1i4q0FR29zjxXhm+2Hop+5Vr/b8tKIvEg== +p-each-series@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a" + integrity sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA== + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -1948,21 +4213,63 @@ p-limit@^2.2.0: dependencies: p-try "^2.0.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +parse-json@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse5@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== + parseurl@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + path-parse@^1.0.6, path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" @@ -1975,6 +4282,43 @@ path-to-regexp@^1.1.1: dependencies: isarray "0.0.1" +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== + +pirates@^4.0.1: + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg== + +pretty-format@^26.0.0, pretty-format@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" + integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== + dependencies: + "@jest/types" "^26.6.2" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^17.0.1" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -1992,6 +4336,14 @@ prom-client@^14.2.0: dependencies: tdigest "^0.1.1" +prompts@^2.0.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + protobufjs@^7.3.0, protobufjs@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" @@ -2015,6 +4367,13 @@ proxy-from-env@^1.1.0: resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +psl@^1.1.33: + version "1.15.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.15.0.tgz#bdace31896f1d97cec6a79e8224898ce93d974c6" + integrity sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w== + dependencies: + punycode "^2.3.1" + pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" @@ -2023,6 +4382,11 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" +punycode@^2.1.1, punycode@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + qs@^6.5.1, qs@^6.5.2, qs@^6.9.4: version "6.10.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" @@ -2035,6 +4399,11 @@ querystring@^0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd" integrity sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + ramda@^0.25.0: version "0.25.0" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" @@ -2055,6 +4424,30 @@ raw-body@^2.3.3: iconv-lite "0.4.24" unpipe "1.0.0" +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +read-pkg-up@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" + integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + dependencies: + find-up "^4.1.0" + read-pkg "^5.2.0" + type-fest "^0.8.1" + +read-pkg@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + readable-stream@^2.0.0, readable-stream@^2.0.5, readable-stream@^2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" @@ -2082,6 +4475,29 @@ redis@^0.12.1: resolved "https://registry.yarnpkg.com/redis/-/redis-0.12.1.tgz#64df76ad0fc8acebaebd2a0645e8a48fac49185e" integrity sha1-ZN92rQ/IrOuuvSoGReikj6xJGF4= +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw== + +repeat-element@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9" + integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ== + +repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -2096,6 +4512,43 @@ require-in-the-middle@^7.1.1: module-details-from-path "^1.0.3" resolve "^1.22.8" +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg== + +resolve@^1.10.0, resolve@^1.18.1: + version "1.22.12" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f" + integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA== + dependencies: + es-errors "^1.3.0" + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^1.22.8: version "1.22.11" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" @@ -2113,6 +4566,23 @@ resolve@^1.3.2: is-core-module "^2.2.0" path-parse "^1.0.6" +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +rimraf@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rsvp@^4.8.4: + version "4.8.5" + resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" + integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== + rwlock@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/rwlock/-/rwlock-5.0.0.tgz#888d6a77a3351cc1a209204ef2ee1722093836cf" @@ -2128,21 +4598,80 @@ safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg== + dependencies: + ret "~0.1.10" + "safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +sane@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-4.1.0.tgz#ed881fd922733a6c461bc189dc2b6c006f3ffded" + integrity sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA== + dependencies: + "@cnakazawa/watch" "^1.0.3" + anymatch "^2.0.0" + capture-exit "^2.0.0" + exec-sh "^0.3.2" + execa "^1.0.0" + fb-watchman "^2.0.0" + micromatch "^3.1.4" + minimist "^1.1.1" + walker "~1.0.5" + +saxes@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" + integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== + dependencies: + xmlchars "^2.2.0" + +"semver@2 || 3 || 4 || 5", semver@^5.5.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@7.x, semver@^7.3.2, semver@^7.5.3: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + semver@^5.3.0, semver@^5.5.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== +semver@^6.3.0, semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + semver@^7.5.2: version "7.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + +set-value@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + setprototypeof@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" @@ -2153,6 +4682,35 @@ setprototypeof@1.2.0: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg== + dependencies: + shebang-regex "^1.0.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ== + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +shellwords@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" + integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== + shimmer@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337" @@ -2167,16 +4725,148 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + slugify@^1.2.6: version "1.6.0" resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.0.tgz#6bdf8ed01dabfdc46425b67e3320b698832ff893" integrity sha512-FkMq+MQc5hzYgM86nLuHI98Acwi3p4wX+a5BO9Hhw4JdK4L7WueIiZ4tXEobImPqBz2sVcV0+Mu3GRB30IGang== +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +source-map-resolve@^0.5.0: + version "0.5.3" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" + integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@^0.5.6: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-url@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56" + integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== + +source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== + +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +source-map@^0.7.3: + version "0.7.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02" + integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== + +spdx-correct@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c" + integrity sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz#5d607d27fc806f66d7b64a766650fa890f04ed66" + integrity sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w== + +spdx-expression-parse@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.23" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz#b069e687b1291a32f126893ed76a27a745ee2133" + integrity sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw== + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +stack-utils@^2.0.2: + version "2.0.6" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g== + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + stats-lite@vtex/node-stats-lite#dist: version "2.2.1" resolved "https://codeload.github.com/vtex/node-stats-lite/tar.gz/a0b5ee91861f31b6ec845146b4906faf5172c430" @@ -2193,6 +4883,14 @@ streamsearch@0.1.2: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= +string-length@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" + integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== + dependencies: + char-regex "^1.0.2" + strip-ansi "^6.0.0" + string-template@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" @@ -2228,6 +4926,21 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-bom@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q== + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -2235,11 +4948,31 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" +supports-color@^7.0.0, supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-hyperlinks@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz#3943544347c1ff90b15effb03fc14ae45ec10624" + integrity sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA== + dependencies: + has-flag "^4.0.0" + supports-color "^7.0.0" + supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + tar-fs@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" @@ -2268,6 +5001,23 @@ tdigest@^0.1.1: dependencies: bintrees "1.0.1" +terminal-link@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" + integrity sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ== + dependencies: + ansi-escapes "^4.2.1" + supports-hyperlinks "^2.0.0" + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + thriftrw@^3.5.0: version "3.12.0" resolved "https://registry.yarnpkg.com/thriftrw/-/thriftrw-3.12.0.tgz#30857847755e7f036b2e0a79d11c9f55075539d9" @@ -2277,6 +5027,48 @@ thriftrw@^3.5.0: error "7.0.2" long "^2.4.0" +throat@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" + integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== + +tmpl@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg== + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg== + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + toidentifier@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" @@ -2295,6 +5087,23 @@ tokenbucket@^0.3.2: bluebird "2.9.24" redis "^0.12.1" +tough-cookie@^4.0.0: + version "4.1.4" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" + integrity sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + +tr46@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" + integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== + dependencies: + punycode "^2.1.1" + ts-invariant@^0.4.0: version "0.4.4" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" @@ -2302,6 +5111,22 @@ ts-invariant@^0.4.0: dependencies: tslib "^1.9.3" +ts-jest@^26.5.6: + version "26.5.6" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.5.6.tgz#c32e0746425274e1dfe333f43cd3c800e014ec35" + integrity sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA== + dependencies: + bs-logger "0.x" + buffer-from "1.x" + fast-json-stable-stringify "2.x" + jest-util "^26.1.0" + json5 "2.x" + lodash "4.x" + make-error "1.x" + mkdirp "1.x" + semver "7.x" + yargs-parser "20.x" + tslib@^1.0.0, tslib@^1.10.0, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" @@ -2370,6 +5195,26 @@ tsutils@^2.29.0: dependencies: tslib "^1.8.1" +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== + +type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + type-is@^1.6.16: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -2378,6 +5223,13 @@ type-is@^1.6.16: media-typer "0.3.0" mime-types "~2.1.24" +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + typescript@3.9.7: version "3.9.7" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" @@ -2388,21 +5240,70 @@ undici-types@~7.18.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== +union-value@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^2.0.1" + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + unpipe@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ== + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +update-browserslist-db@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz#a71c28dd22f505481dbc4689087b18d933e90afd" + integrity sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + urijs@^1.19.0: version "1.19.6" resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.6.tgz#51f8cb17ca16faefb20b9a31ac60f84aa2b7c870" integrity sha512-eSXsXZ2jLvGWeLYlQA3Gh36BcjF+0amo92+wHPyN1mdR8Nxf75fuEuYTd9c0a+m/vhCjRK0ESlE9YNLW+E1VEw== +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg== + +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -2418,11 +5319,28 @@ uuid@^3.1.0, uuid@^3.3.3: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== -uuid@^8.3.2: +uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +v8-to-istanbul@^7.0.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-7.1.2.tgz#30898d1a7fa0c84d225a2c1434fb958f290883c1" + integrity sha512-TxNb7YEUwkLXCQYeudi6lgQ/SZrzNO4kMdlqVxaZPUIUjCv6iSSypUQX70kNBSERpQ8fk48+d61FXk+tgqcWow== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^1.6.0" + source-map "^0.7.3" + +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + vary@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -2448,6 +5366,86 @@ vary@^1.1.2: version "9.146.3" resolved "http://vtex.vtexassets.com/_v/public/typings/v1/vtex.styleguide@9.146.3/public/@types/vtex.styleguide#05558160f29cd8f4aefe419844a4bd66e2b3fdbb" +w3c-hr-time@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" + integrity sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ== + dependencies: + browser-process-hrtime "^1.0.0" + +w3c-xmlserializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a" + integrity sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA== + dependencies: + xml-name-validator "^3.0.0" + +walker@^1.0.7, walker@~1.0.5: + version "1.0.8" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" + integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== + dependencies: + makeerror "1.0.12" + +webidl-conversions@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" + integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== + +webidl-conversions@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" + integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== + +whatwg-encoding@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" + integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== + dependencies: + iconv-lite "0.4.24" + +whatwg-mimetype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" + integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== + +whatwg-url@^8.0.0, whatwg-url@^8.5.0: + version "8.7.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" + integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== + dependencies: + lodash "^4.7.0" + tr46 "^2.1.0" + webidl-conversions "^6.1.0" + +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + +which@^1.2.9: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.1, which@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -2462,6 +5460,31 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= +write-file-atomic@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + +ws@^7.4.6: + version "7.5.13" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.13.tgz#12aa507eaca76c295c278b1aebf4698ab2c1845f" + integrity sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA== + +xml-name-validator@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" + integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + xorshift@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/xorshift/-/xorshift-1.2.0.tgz#30a4cdd8e9f8d09d959ed2a88c42a09c660e8148" @@ -2480,6 +5503,11 @@ xtend@^4.0.0, xtend@~4.0.0: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -2490,11 +5518,41 @@ yallist@^3.0.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== +yargs-parser@20.x: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== +yargs@^15.4.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" diff --git a/vtex.session/configuration.json b/vtex.session/configuration.json index 92b63f73..6c902996 100644 --- a/vtex.session/configuration.json +++ b/vtex.session/configuration.json @@ -8,7 +8,7 @@ "storefront-permissions": ["hash"] }, "output": { - "public": ["facets", "sc", "regionId"], + "public": ["facets", "sc", "regionId", "postalCode", "country"], "storefront-permissions": ["storeUserId", "storeUserEmail", "organization", "costcenter", "costCenterAddressId", "priceTables", "collections", "userId", "hash"] } } From 342093082291bc06faaf95b8c888e1af70f5a097 Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Thu, 20 Aug 2026 19:46:36 -0300 Subject: [PATCH 02/19] fix: address Copilot review findings on PR #203 - Inactive-organization recovery fetched the organizations document with the b2b_users record id instead of the organization id, which still resolved to undefined and preserved the 500 this branch fixes. It also kept stamping the response and cart with the inactive organization's data; the fallback is now adopted locally (user, session fields, hash, cost center refetch) so the same request reflects the activated org. - getActiveUserByEmail resolves Master Data failures into an error sentinel; the fetcher now rethrows it so neither cache layer stores an outage as a valid user (which produced empty B2B sessions until the TTL expired). Failures are handled outside the cached call so they are retried, in both setProfile and checkPermissions. - The user-independent promises started before the early-return user checks now get an immediate rejection observer, so an early return can no longer leave an unhandled rejection. - sessionUserCacheTtlMs is stored per account/workspace instead of process-globally (a pod serves multiple tenants), and setting it to 0 now bypasses the VBase layer too, matching its documentation. - Tests: the inactive-org mock is id-exact so the wrong-id lookup fails the suite; the recovery test asserts the response is stamped with the activated organization; the CL-profile test now actually reaches the hanging cart update instead of passing vacuously. --- manifest.json | 2 +- node/__tests__/setProfile.test.ts | 52 ++++++++++++++---- node/resolvers/Routes/index.ts | 88 ++++++++++++++++++++++++------- node/services/activeUserCache.ts | 20 +++++-- node/services/cache.ts | 5 +- 5 files changed, 133 insertions(+), 34 deletions(-) diff --git a/manifest.json b/manifest.json index 8fa41237..3a5018b2 100644 --- a/manifest.json +++ b/manifest.json @@ -177,7 +177,7 @@ }, "sessionUserCacheTtlMs": { "title": "Active user cache TTL (ms)", - "description": "How long the resolved active user (organization and cost center for an email) is cached. The session transform runs several times per storefront navigation, so this removes most of the repeated Master Data lookups. The cache key already includes the session's b2bCurrentCostCenter, so switching organization invalidates it immediately; this TTL only bounds changes made outside that flow, such as an admin editing the user's organizations. Set to 0 to disable. Defaults to 300000ms (5 minutes).", + "description": "How long the resolved active user (organization and cost center for an email) is cached in memory. The session transform runs several times per storefront navigation, so this removes most of the repeated Master Data lookups. The cache key already includes the session's b2bCurrentCostCenter, so switching organization invalidates it immediately; this TTL only bounds changes made outside that flow, such as an admin editing the user's organizations. Note the cross-pod layer keeps its own 5-minute freshness window, so values below 5 minutes only tighten the in-memory layer. Set to 0 to disable caching for this lookup entirely (both layers). Defaults to 300000ms (5 minutes).", "type": "number", "default": 300000 }, diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts index caf00e7c..5a443a42 100644 --- a/node/__tests__/setProfile.test.ts +++ b/node/__tests__/setProfile.test.ts @@ -2,7 +2,10 @@ import { json } from 'co-body' import { Routes } from '../resolvers/Routes' -import { getUserOrganizationsData } from '../resolvers/Routes/utils' +import { + generateClUser, + getUserOrganizationsData, +} from '../resolvers/Routes/utils' jest.mock('co-body', () => ({ json: jest.fn() })) @@ -79,16 +82,24 @@ const makeCtx = (scenario: Scenario = {}) => { updateSalesChannel: jest.fn().mockResolvedValue({}), }, masterDataExtended: { + // Deliberately id-exact: the recovered organization only resolves for + // its real organization id ('org2'). Fetching with any other id (for + // example the b2b_users record id 'u2') returns undefined, exactly like + // Master Data would - which is how the wrong-id lookup bug is caught. getDocumentById: jest.fn().mockImplementation((entity, id) => { - if ( - entity === 'organizations' && - recoveredOrganization && - id !== 'org1' - ) { + if (entity !== 'organizations') { + return Promise.resolve(undefined) + } + + if (id === 'org1') { + return Promise.resolve(organization) + } + + if (recoveredOrganization && id === 'org2') { return Promise.resolve(recoveredOrganization) } - return Promise.resolve(organization) + return Promise.resolve(undefined) }), }, masterdata: { @@ -271,11 +282,21 @@ describe('setProfile', () => { }) // With the old `.data.getOrganizationById` unwrap this threw a TypeError - // and returned a 500; the fix must complete normally. - await run(ctx) + // and returned a 500; the fix must complete normally. The id-exact mock in + // makeCtx also fails this test if the lookup uses the user record id ('u2') + // instead of the organization id ('org2'). + const response = await run(ctx) expect(ctx.response.status).toBe(200) expect(getUserOrganizationsData).toHaveBeenCalled() + + // The response must be stamped with the organization that was just + // activated, not the inactive one it arrived with. + expect(response['storefront-permissions'].organization.value).toBe('org2') + expect(response['storefront-permissions'].costcenter.value).toBe('cost2') + expect(ctx.clients.organizations.getCostCenterById).toHaveBeenCalledWith( + 'cost2' + ) }) it('keeps full payload logging off unless logSessionPayloads is enabled', async () => { @@ -324,6 +345,17 @@ describe('setProfile', () => { }) it('does not block the response on the CL profile update', async () => { + // The suite-level mock resolves null, which would skip the cart update and + // make this test pass vacuously; return a real CL user so the hanging + // update below is actually reached. + const clUserMock = generateClUser as jest.Mock + + clUserMock.mockResolvedValueOnce({ + email: 'buyer@test.com', + isCorporate: true, + phone: null, + }) + const ctx = makeCtx() // Even if the cart profile update hangs forever, the response returns. @@ -335,5 +367,7 @@ describe('setProfile', () => { expect(response.public.facets).toBeDefined() expect(ctx.response.status).toBe(200) + // Proves the fire-and-forget update genuinely started while still pending. + expect(ctx.clients.checkout.updateOrderFormProfile).toHaveBeenCalledTimes(1) }) }) diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index 837fed1b..20aaa146 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -280,15 +280,36 @@ export const Routes = { getCachedAppSettings(ctx) ) + // These two start before the user checks below, so an early return would + // leave their rejections unhandled and crash the worker. Observing here + // marks them handled; the awaits further down still see the real rejection. + salesChannelsPromise.catch(() => undefined) + appSettingsPromise.catch(() => undefined) + if (user === null) { - user = (await timer.track( - 'getActiveUserByEmail', - getCachedActiveUserByEmail(ctx, email, currentCostCenter, () => - getActiveUserByEmail(null, { email }, ctx).catch((error) => { - logger.warn({ message: 'setProfile.getUserByEmailError', error }) - }) + // getActiveUserByEmail resolves Master Data failures into an error + // sentinel instead of rejecting. Rethrow it inside the fetcher so neither + // cache layer can store an outage as a valid "user without organization" + // (which would produce empty B2B sessions until the TTL expired), and + // handle the failure outside the cached call so it is retried next time. + const fetchActiveUser = async () => { + const activeUser: any = await getActiveUserByEmail(null, { email }, ctx) + + if (activeUser?.status === 'error') { + throw activeUser.message + } + + return activeUser + } + + user = (await timer + .track( + 'getActiveUserByEmail', + getCachedActiveUserByEmail(ctx, email, currentCostCenter, fetchActiveUser) ) - )) as { + .catch((error) => { + logger.warn({ message: 'setProfile.getUserByEmailError', error }) + })) as { orgId: string costId: string clId: string @@ -356,15 +377,21 @@ export const Routes = { const resolvedCostId = user.costId // Only these two genuinely depend on the resolved user (orgId/costId). - const [organizationResponse, costCenterResponse] = await Promise.all([ - timer.track('getOrganization', getOrganization(user.orgId)), - timer.track( - 'getCostCenterById', - getCachedCostCenter(ctx, String(resolvedCostId), () => - organizations.getCostCenterById(resolvedCostId) - ) - ), - ]) + // costCenterResponse is reassigned when the inactive-organization fallback + // below adopts a different cost center. + const [organizationResponse, initialCostCenterResponse] = await Promise.all( + [ + timer.track('getOrganization', getOrganization(user.orgId)), + timer.track( + 'getCostCenterById', + getCachedCostCenter(ctx, String(resolvedCostId), () => + organizations.getCostCenterById(resolvedCostId) + ) + ), + ] + ) + + let costCenterResponse: any = initialCostCenterResponse // These were started earlier; by now their latency is largely hidden // behind the user + organization lookups above. Tracking the wait itself @@ -374,7 +401,7 @@ export const Routes = { Promise.all([salesChannelsPromise, appSettingsPromise]) ) - setActiveUserCacheTtl((appSettings as any)?.sessionUserCacheTtlMs) + setActiveUserCacheTtl(ctx, (appSettings as any)?.sessionUserCacheTtlMs) // Hand the account's limits to the middleware that emits the timings. timer.meta.sampleRate = (appSettings as any)?.sessionTimingsSampleRate @@ -445,8 +472,31 @@ export const Routes = { // getOrganization reads Master Data directly, so it returns the document // itself. Unwrapping `.data.getOrganizationById` here is left over from // when this went through the b2b-organizations GraphQL client, and it - // resolved to undefined, throwing on the `organization.name` access below. - organization = await getOrganization(validOrganization.id) + // resolved to undefined, throwing on the `organization.name` access + // below. Note `validOrganization.id` is the b2b_users record id, not the + // organization id, so the lookup must use `orgId`. + organization = await getOrganization(validOrganization.orgId) + + // Adopt the fallback locally as well, so this response is stamped with + // the organization we just activated instead of the inactive one: the + // session fields, the hash, and the cost center data the cart updates + // below read from all came from the old organization. + const fallbackCostId = validOrganization.costId + + user.orgId = validOrganization.orgId + user.costId = fallbackCostId + response['storefront-permissions'].organization.value = user.orgId + response['storefront-permissions'].hash.value = toHash( + `${user.orgId}|${user.costId}` + ) + timer.meta.extra = { ...timer.meta.extra, orgId: user.orgId } + + costCenterResponse = await timer.track( + 'getCostCenterById.inactiveFallback', + getCachedCostCenter(ctx, String(fallbackCostId), () => + organizations.getCostCenterById(fallbackCostId) + ) + ) await setActiveUserByOrganization( null, diff --git a/node/services/activeUserCache.ts b/node/services/activeUserCache.ts index 33a8a402..0a5e1518 100644 --- a/node/services/activeUserCache.ts +++ b/node/services/activeUserCache.ts @@ -23,7 +23,14 @@ const cachedActiveUser = createCachedResource('active-user', { vbaseTtlMinutes: ACTIVE_USER_CACHE_TTL_IN_MINUTES, }) -let configuredTtlMs: number | undefined +/** + * Per tenant: a pod serves multiple accounts/workspaces, so a single global + * value would let whichever account most recently read its settings dictate + * the TTL for every other tenant on the pod. + */ +const configuredTtlMsByTenant = new Map() + +const tenantKey = (ctx: Context) => `${ctx.vtex.account}-${ctx.vtex.workspace}` /** * The TTL is configurable, but this lookup happens before app settings are @@ -31,9 +38,13 @@ let configuredTtlMs: number | undefined * the configured value is recorded once a request has read the settings and * applies from the next request onwards, which is fine for a TTL knob. */ -export const setActiveUserCacheTtl = (ttlMs?: unknown) => { +export const setActiveUserCacheTtl = (ctx: Context, ttlMs?: unknown) => { if (typeof ttlMs === 'number' && ttlMs >= 0) { - configuredTtlMs = ttlMs + configuredTtlMsByTenant.set(tenantKey(ctx), ttlMs) + } else { + // Setting removed: fall back to the default rather than retaining a stale + // configured value. + configuredTtlMsByTenant.delete(tenantKey(ctx)) } } @@ -44,7 +55,8 @@ export const getCachedActiveUserByEmail = async ( fetcher: () => Promise ): Promise => cachedActiveUser(ctx, `${email}|${currentCostCenter ?? 'default'}`, fetcher, { - memoryTtlMs: configuredTtlMs ?? ACTIVE_USER_CACHE_TTL_IN_MS, + memoryTtlMs: + configuredTtlMsByTenant.get(tenantKey(ctx)) ?? ACTIVE_USER_CACHE_TTL_IN_MS, }) /** diff --git a/node/services/cache.ts b/node/services/cache.ts index c4fbc0d6..9e9cdd1d 100644 --- a/node/services/cache.ts +++ b/node/services/cache.ts @@ -112,7 +112,10 @@ export const createCachedResource = ( : fetcher() if (memoryTtlMs <= 0) { - return readThrough() + // Explicitly disabled means disabled: bypass the VBase layer too, not + // just the memory one, otherwise "0" would still serve values up to the + // VBase TTL old. + return fetcher() } const { account, workspace } = ctx.vtex From 64691be04e9736cdd38491f7a038f7259f2661d6 Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Thu, 20 Aug 2026 19:47:27 -0300 Subject: [PATCH 03/19] fix: checkPermissions fetcher also rethrows the error sentinel so Master Data failures are never cached as users --- node/resolvers/Routes/index.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index 20aaa146..6be7cba4 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -62,10 +62,23 @@ export const Routes = { // Same array shape getUserByEmail returned, but served from the short-lived // permissions cache: this route is called per request by sibling B2B apps. + // The fetcher rethrows getActiveUserByEmail's resolved error sentinel so a + // Master Data failure is never cached as a user; the catch reconstitutes it + // to preserve the route's original (uncached) behavior for this request. const userData: any = [ - await getCachedActiveUserForPermissions(ctx, params.email, () => - getActiveUserByEmail(null, { email: params.email }, ctx) - ), + await getCachedActiveUserForPermissions(ctx, params.email, async () => { + const activeUser: any = await getActiveUserByEmail( + null, + { email: params.email }, + ctx + ) + + if (activeUser?.status === 'error') { + throw activeUser.message + } + + return activeUser + }).catch((message) => ({ message, status: 'error' })), ] if (!userData.length) { From 022dd8cf6201c2232b650330d799ac8332e18f2d Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Fri, 21 Aug 2026 12:18:45 -0300 Subject: [PATCH 04/19] fix: never cache lookup failures or misses; recompute hash on org recovery Three findings from a deep review of the branch: - A transient organization lookup failure resolved to undefined and was stored by both cache layers, turning one Master Data blip into minutes of errors served from cache. The fetcher now rethrows, so a failure fails only its own request and the next one retries. - A user lookup that found no B2B user was cached for the full TTL, pinning shoppers hit by replication lag (e.g. right after being added to an organization) to an empty B2B session. Misses now bypass the cache in both setProfile and checkPermissions, restoring the pre-cache behavior for non-B2B shoppers. - The inactive-organization recovery adopted the fallback org but kept hashChanged computed against the old organization, so a session whose hash matched the inactive org skipped the clearCart branch despite the shopper moving organizations. hashChanged is now recomputed from the adopted organization. Each fix carries a regression test (44 total). --- node/__tests__/setProfile.test.ts | 83 +++++++++++++++++++++++++++++++ node/resolvers/Routes/index.ts | 55 +++++++++++++++++--- 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts index 5a443a42..f7d8314c 100644 --- a/node/__tests__/setProfile.test.ts +++ b/node/__tests__/setProfile.test.ts @@ -6,6 +6,7 @@ import { generateClUser, getUserOrganizationsData, } from '../resolvers/Routes/utils' +import { toHash } from '../utils' jest.mock('co-body', () => ({ json: jest.fn() })) @@ -299,6 +300,88 @@ describe('setProfile', () => { ) }) + it('clears the cart on inactive-org recovery even when the session hash matched the old org', async () => { + const orgsDataMock = getUserOrganizationsData as jest.Mock + + orgsDataMock.mockResolvedValue({ + activeOrganization: { costId: 'cost2', id: 'u2', orgId: 'org2' }, + validCostCenterId: null, + }) + + const ctx = makeCtx({ + organization: { + collections: null, + name: 'Inactive Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'inactive', + tradeName: null, + }, + recoveredOrganization: { + collections: null, + name: 'Recovered Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + }) + + ctx.clients.organizations.getB2BSettings.mockResolvedValue({ + data: { getB2BSettings: { uiSettings: { clearCart: true } } }, + }) + + // The session arrives with the hash of the (now inactive) org1/cost1, so + // the pre-recovery hashChanged is false; without recomputation the cart + // would keep the old organization's items. + const response = await run(ctx, { + ...makeBody(), + 'storefront-permissions': { hash: { value: toHash('org1|cost1') } }, + }) + + expect(response['storefront-permissions'].hash.value).toBe( + toHash('org2|cost2') + ) + expect(ctx.clients.checkout.clearCart).toHaveBeenCalledWith('of123') + }) + + it('does not cache a failed organization lookup', async () => { + const ctx = makeCtx() + + ctx.clients.masterDataExtended.getDocumentById.mockRejectedValueOnce( + new Error('master data blip') + ) + + // The failing request errors instead of resolving with a broken session... + await expect(run(ctx)).rejects.toThrow('master data blip') + + // ...and the very next request retries the origin instead of reading a + // cached empty organization for the whole TTL. + const response = await run(ctx) + + expect(response['storefront-permissions'].organization.value).toBe('org1') + }) + + it('does not cache a user that was not found', async () => { + const ctx = makeCtx() + const lookups = ctx.clients.masterdata.searchDocumentsWithPaginationInfo + + lookups.mockResolvedValueOnce({ data: [], pagination: { page: 1, total: 0 } }) + + // First transform: user not provisioned yet, empty B2B session. + const first = await run(ctx) + + expect(first['storefront-permissions'].organization.value).toBe('') + + // Second transform: the user now exists and must be found immediately - + // a cached miss would pin the empty session for the whole TTL. + const second = await run(ctx) + + expect(second['storefront-permissions'].organization.value).toBe('org1') + }) + it('keeps full payload logging off unless logSessionPayloads is enabled', async () => { const quiet = makeCtx() diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index 6be7cba4..b3a3b41b 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -77,8 +77,22 @@ export const Routes = { throw activeUser.message } + // A miss must not be cached (replication lag would pin it); throwing + // keeps it out of the cache, and the catch below restores the exact + // uncached shape this route always produced for a missing user. + if (!activeUser?.id) { + const notFound: any = new Error('checkPermissions.userNotFound') + + notFound.userNotFound = true + throw notFound + } + return activeUser - }).catch((message) => ({ message, status: 'error' })), + }).catch((error) => + error?.userNotFound + ? { email: '', name: '' } + : { message: error, status: 'error' } + ), ] if (!userData.length) { @@ -312,6 +326,18 @@ export const Routes = { throw activeUser.message } + // "No B2B user" must not be cached either: right after provisioning or + // during replication lag, caching the miss would pin this shopper to an + // empty B2B session for the whole TTL. Throwing keeps the miss out of + // both layers, which simply restores the pre-cache behavior (a lookup + // per transform) for non-B2B shoppers. + if (!activeUser?.orgId || !activeUser?.costId) { + const notFound: any = new Error('setProfile.userNotFound') + + notFound.userNotFound = true + throw notFound + } + return activeUser } @@ -321,7 +347,9 @@ export const Routes = { getCachedActiveUserByEmail(ctx, email, currentCostCenter, fetchActiveUser) ) .catch((error) => { - logger.warn({ message: 'setProfile.getUserByEmailError', error }) + if (!error?.userNotFound) { + logger.warn({ message: 'setProfile.getUserByEmailError', error }) + } })) as { orgId: string costId: string @@ -358,12 +386,19 @@ export const Routes = { error, message: 'setProfile.graphqlGetOrganizationById', }) + + // Rethrow so a transient Master Data failure fails only this + // request. Swallowing it here would make the cache store an empty + // organization for its full TTL, turning one blip into minutes of + // errors served from cache. + throw error }) ) } + // Reassigned by the inactive-organization fallback below. const hash = toHash(`${user.orgId}|${user.costId}`) - const hashChanged = body?.['storefront-permissions']?.hash?.value !== hash + let hashChanged = body?.['storefront-permissions']?.hash?.value !== hash response['storefront-permissions'].hash.value = hash @@ -499,9 +534,17 @@ export const Routes = { user.orgId = validOrganization.orgId user.costId = fallbackCostId response['storefront-permissions'].organization.value = user.orgId - response['storefront-permissions'].hash.value = toHash( - `${user.orgId}|${user.costId}` - ) + + // Recompute against the adopted organization: the value derived above + // used the inactive org's hash, so a session that matched it would + // report "unchanged" and skip the clearCart branch even though the + // shopper just moved organizations. + const fallbackHash = toHash(`${user.orgId}|${user.costId}`) + + response['storefront-permissions'].hash.value = fallbackHash + hashChanged = + body?.['storefront-permissions']?.hash?.value !== fallbackHash + timer.meta.extra = { ...timer.meta.extra, orgId: user.orgId } costCenterResponse = await timer.track( From 63f94d49a47f10f2d2002c9f4432add3f78da273 Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Fri, 21 Aug 2026 12:24:08 -0300 Subject: [PATCH 05/19] fix: address second Copilot review round - Clone the cached active user before request-local fallback branches reassign orgId/costId on it: the LRU hands out the same reference, so those mutations corrupted the shared cache entry under its original key for every later request. - Roles cache TTL lowered from 5 minutes to 60 seconds: saveRole and deleteRole write VBase but cannot invalidate other pods' memory caches, so the TTL is the upper bound on how long a revoked permission stays effective. Documented in the caching doc. - (The third finding - organization lookup failures being cached - was already fixed in 022dd8c; that review ran against the previous head.) --- docs/PERFORMANCE_AND_CACHING.md | 3 ++- node/__tests__/setProfile.test.ts | 44 +++++++++++++++++++++++++++++++ node/resolvers/Routes/index.ts | 10 +++++-- node/services/rolesCache.ts | 4 +++ node/utils/constants.ts | 9 +++++-- 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/docs/PERFORMANCE_AND_CACHING.md b/docs/PERFORMANCE_AND_CACHING.md index 0c42e60a..12e1a7ac 100644 --- a/docs/PERFORMANCE_AND_CACHING.md +++ b/docs/PERFORMANCE_AND_CACHING.md @@ -43,7 +43,7 @@ All caches are built by `createCachedResource` (`node/services/cache.ts`), with | `active-user-permissions` | Master Data (paginated) | memory only | 60s | — | 10000 | email | | `region` | checkout REST | both | 30min | 30min | 10000 | `country\|postalCode\|sc\|geo` | | `session-watcher` | VBase | memory only | 60s | — | 100 | `active` | -| `roles` | VBase (MD fallback) | memory only | 5min | — | 100 | `all` | +| `roles` | VBase (MD fallback) | memory only | 60s | — | 100 | `all` | ¹ Configurable via the `sessionUserCacheTtlMs` app setting; `0` disables. @@ -53,6 +53,7 @@ All caches are built by `createCachedResource` (`node/services/cache.ts`), with - **App settings (5+5min):** feature flags an operator may flip; worst-case propagation is roughly memory TTL + VBase TTL (~10 minutes), because the memory layer holds its entry for its TTL and then may read a stale VBase entry once before the background refresh lands. - **Organization / cost center (60s/2min):** deliberately short — `organization.status === 'inactive'` blocks the user (`ForbiddenError`), so deactivating an organization must take effect within minutes. - **Session watcher (60s):** it is the operational kill switch; disabling it must bite quickly. +- **Roles (60s):** authorization data. Role mutations write VBase but cannot invalidate other pods' memory caches, so this TTL is the upper bound on how long a revoked permission stays effective. - **Active user:** the TTL is only a safety net. The cache key contains the session's `public.b2bCurrentCostCenter`, which `setCurrentOrganization` writes on every organization switch — so a switch changes the key and misses the cache immediately, regardless of TTL. The TTL covers changes that bypass that mutation (an admin editing a user's organizations, the inactive-org fallback). - **`active-user-permissions` (60s, memory only):** the `checkPermissions` route receives only `app` + `email`, so there is no cost center to key on and no key-based invalidation. Short TTL bounds how long stale permissions can survive an organization switch; no VBase layer so nothing extends that window. diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts index f7d8314c..4aaa6b64 100644 --- a/node/__tests__/setProfile.test.ts +++ b/node/__tests__/setProfile.test.ts @@ -347,6 +347,50 @@ describe('setProfile', () => { expect(ctx.clients.checkout.clearCart).toHaveBeenCalledWith('of123') }) + it('does not let fallback branches mutate the cached user entry', async () => { + const orgsDataMock = getUserOrganizationsData as jest.Mock + + // The mock is module-level and earlier tests already invoked it; this test + // asserts on call counts, so start from zero. + orgsDataMock.mockClear() + orgsDataMock.mockResolvedValue({ + activeOrganization: { costId: 'cost2', id: 'u2', orgId: 'org2' }, + validCostCenterId: null, + }) + + const ctx = makeCtx({ + organization: { + collections: null, + name: 'Inactive Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'inactive', + tradeName: null, + }, + recoveredOrganization: { + collections: null, + name: 'Recovered Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + }) + + // First run goes through the inactive-org recovery, which rewrites + // user.orgId/costId locally. + await run(ctx) + expect(orgsDataMock).toHaveBeenCalledTimes(1) + + // Second run hits the active-user cache. If recovery had mutated the + // shared cached object, this run would start from org2 and skip recovery + // entirely; a pristine entry must re-enter the recovery path. + await run(ctx) + expect(orgsDataMock).toHaveBeenCalledTimes(2) + }) + it('does not cache a failed organization lookup', async () => { const ctx = makeCtx() diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index b3a3b41b..9fbc2da9 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -341,7 +341,7 @@ export const Routes = { return activeUser } - user = (await timer + const cachedUser: any = await timer .track( 'getActiveUserByEmail', getCachedActiveUserByEmail(ctx, email, currentCostCenter, fetchActiveUser) @@ -350,7 +350,13 @@ export const Routes = { if (!error?.userNotFound) { logger.warn({ message: 'setProfile.getUserByEmailError', error }) } - })) as { + }) + + // Clone: the memory cache hands out the same object reference, and the + // invalid-cost-center / inactive-organization branches below reassign + // orgId/costId on it. Mutating the shared entry would corrupt the cache + // under its original key for every later request. + user = (cachedUser ? { ...cachedUser } : cachedUser) as { orgId: string costId: string clId: string diff --git a/node/services/rolesCache.ts b/node/services/rolesCache.ts index 1ef7e398..3cb1b6e0 100644 --- a/node/services/rolesCache.ts +++ b/node/services/rolesCache.ts @@ -5,6 +5,10 @@ import { createCachedResource } from './cache' * Roles are account-level, change only through the admin, and are read on every * permission check. The source is VBase (with a Master Data fallback), so an * in-memory layer is the only one that helps here. + * + * The TTL is kept to one minute because this is authorization data: saveRole / + * deleteRole write VBase but cannot invalidate the memory layer on other pods, + * so the TTL bounds how long a revoked permission can remain effective. */ const cachedRoles = createCachedResource('roles', { maxEntries: 100, diff --git a/node/utils/constants.ts b/node/utils/constants.ts index 032869c7..cfe4a37a 100644 --- a/node/utils/constants.ts +++ b/node/utils/constants.ts @@ -34,8 +34,13 @@ export const APP_SETTINGS_CACHE_TTL_IN_MINUTES = 5 // In-memory only (VBase-sourced), and short: this flag is a kill switch. export const SESSION_WATCHER_CACHE_TTL_IN_MS = 60 * 1000 -// In-memory only (VBase-sourced); roles change rarely and only via the admin. -export const ROLES_CACHE_TTL_IN_MS = 5 * 60 * 1000 +/** + * In-memory only (VBase-sourced). Deliberately short: this is authorization + * data and role mutations cannot invalidate other pods' caches, so the TTL is + * the upper bound on how long a permission revoked in the admin stays + * effective. + */ +export const ROLES_CACHE_TTL_IN_MS = 60 * 1000 /** * Resolves the *active* organization of a user. The cache key includes the From 314f0d99e60a80fe33149d13f11f47801f47eb0c Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Fri, 21 Aug 2026 12:32:18 -0300 Subject: [PATCH 06/19] docs: add cache correctness rules distilled from review findings Never cache failures, never cache transient misses, never mutate cached objects - each rule grounded in the concrete bug both review rounds found and pointing at the regression test that guards it. --- docs/PERFORMANCE_AND_CACHING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/PERFORMANCE_AND_CACHING.md b/docs/PERFORMANCE_AND_CACHING.md index 12e1a7ac..af9da255 100644 --- a/docs/PERFORMANCE_AND_CACHING.md +++ b/docs/PERFORMANCE_AND_CACHING.md @@ -30,6 +30,18 @@ All caches are built by `createCachedResource` (`node/services/cache.ts`), with **Rule: only add the VBase layer when the origin is expensive** (Apps API, Master Data, another app's GraphQL, checkout). For data that already lives in VBase — the session watcher flag, roles — a VBase-backed cache would just swap one VBase read for another; those caches are memory-only. +### Cache correctness rules + +Every substantive bug found in review of this architecture was a variant of the same mistake: **letting the cache hold a state the origin never produced.** A cache entry is a claim — "this is what the origin returned for this key" — and each rule below protects that claim. Break one and the cache serves wrong responses repeatedly, for the full TTL, to every request that hits it. + +1. **Never cache a failure.** A fetcher must rethrow errors, not swallow them into `undefined`/`null`. A swallowed failure gets stored by both layers, turning one transient Master Data blip into minutes of errors served from cache — after the origin has already recovered. Log at the fetcher if useful, but always rethrow; handle the failure *outside* the cached call so the next request retries. (Guarded by the "does not cache a failed organization lookup" test.) + +2. **Never cache a miss that can be transient.** "User not found" during replication lag — right after someone is added to an organization — is not a fact, it is a race. Caching it pins that shopper to an empty B2B session for the whole TTL. When a miss can be transient, throw a typed marker from the fetcher so nothing is stored, and translate it back at the call site; the cost is one origin lookup per request for that population, which is exactly the pre-cache behavior. (Guarded by the "does not cache a user that was not found" test.) + +3. **Never mutate an object returned by a cache.** The memory layer hands out the *same object reference* on every hit, so reassigning a field on it rewrites the shared entry under its original key — every later request receives request-local surgery the origin never returned, and the VBase layer (which stored a serialized snapshot) now *disagrees* with memory, making behavior depend on which layer answers. Treat cached values as read-only; if a request needs to modify one, shallow-clone at the boundary (`{ ...cached }`) — and remember nested arrays/objects are still shared, so deeper mutation needs a deeper copy. (Guarded by the "does not let fallback branches mutate the cached user entry" test.) + +Corollary for reviews: when a change touches a fetcher or anything downstream of a cached read, ask "can this store or corrupt a state the origin didn't produce?" before asking anything about performance. + ### Current resources | Resource | Origin | Layers | Memory TTL | VBase TTL | Bound | Key | From aa1a897b011762cfb831ab5d93a8d0a164751630 Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Sat, 22 Aug 2026 12:32:11 -0300 Subject: [PATCH 07/19] fix: stabilize organization resolution and stop logging raw client errors - Resolve the active B2B record with an active=true filtered Master Data query (0..1 records, single call) instead of paginating every record for the email and falling back to an arbitrary users[0] - Keep the organization and cost center the session already carries: storefront-permissions.organization/costcenter become transform inputs (same pattern as hash) resolved through targeted single-call lookups, so the resolution no longer drifts between requests - Evaluate organization status in one module mirroring b2b-organizations (only 'active' is usable); unknown statuses fail closed and are reported - Sanitize the characters checkout rejects (CHK0040) from the two annotation address fields (reference, complement); location-bearing fields are reported via CART_ADDRESS_FIELD_REJECTED and never rewritten, established by probing which of the 13 address fields checkout validates - Route all 63 error logs through describeClientError: keeps message, codes, status and the correlation ids VTEX backends answer with (operationId, requestId, backend), never the request body or query string - Organization lookups tolerate a hard 404 (document gone) without caching the miss, and the recovery path validates the fallback before adopting it - Docs: organization-data source rationale with measurements, new observability signals, and a public-repo hygiene pass over comments/docs --- CHANGELOG.md | 11 +- docs/OBSERVABILITY.md | 40 +- docs/PERFORMANCE_AND_CACHING.md | 31 +- .../TESTING_COST_CENTER_ADDRESS_AND_REGION.md | 2 +- node/__tests__/checkoutAddress.test.ts | 147 ++++ node/__tests__/clientError.test.ts | 98 +++ node/__tests__/observabilityEvent.test.ts | 61 ++ node/__tests__/setProfile.test.ts | 679 +++++++++++++++++- node/clients/metrics.ts | 6 +- node/directives/helper.ts | 7 +- node/metrics/auth.ts | 3 +- node/metrics/session.ts | 3 +- node/resolvers/Mutations/Profiles.ts | 5 +- node/resolvers/Mutations/Roles.ts | 5 +- node/resolvers/Mutations/Settings.ts | 3 +- node/resolvers/Mutations/Users.ts | 35 +- node/resolvers/Queries/Profiles.ts | 7 +- node/resolvers/Queries/Roles.ts | 3 +- node/resolvers/Queries/Settings.ts | 5 +- node/resolvers/Queries/Users.ts | 142 +++- node/resolvers/Routes/index.ts | 358 +++++++-- node/resolvers/Routes/utils/index.ts | 36 +- node/services/activeUserCache.ts | 28 +- node/utils/checkoutAddress.ts | 142 ++++ node/utils/clientError.ts | 67 ++ node/utils/constants.ts | 4 +- node/utils/observabilityEvent.ts | 54 ++ node/utils/organizationStatus.ts | 38 + node/utils/staleFromVBaseWhileRevalidate.ts | 7 +- vtex.session/configuration.json | 2 +- 30 files changed, 1868 insertions(+), 161 deletions(-) create mode 100644 node/__tests__/checkoutAddress.test.ts create mode 100644 node/__tests__/clientError.test.ts create mode 100644 node/__tests__/observabilityEvent.test.ts create mode 100644 node/utils/checkoutAddress.ts create mode 100644 node/utils/clientError.ts create mode 100644 node/utils/observabilityEvent.ts create mode 100644 node/utils/organizationStatus.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da5dc260..92471bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Per-pod cache hit-rate and size stats logged as `cacheStats` every five minutes, piggybacked on the session transform route. - New app setting `logSessionPayloads` (default `false`) gating the full request/response session payload log, which previously ran on every transform and included the shopper's email and organization data. - Errors in the stale-while-revalidate cache layer are now logged (`staleFromVBase.readError`, `saveError`, `revalidateError`) instead of being silently swallowed - in particular a failing origin behind a stale-served cache is now visible. -- Jest test suite (41 tests) covering the caches, the stale-while-revalidate helper, the timings middleware, the `checkPermissions` cache, and `setProfile` behaviors: sales channel deferral, region handoff and its fallbacks, organization-switch cache invalidation, payload log gating, session watcher kill switch, and the inactive-organization recovery path. +- Jest test suite (77 tests) covering the caches, the stale-while-revalidate helper, the timings middleware, the `checkPermissions` cache, and `setProfile` behaviors: sales channel deferral, region handoff and its fallbacks, organization-switch cache invalidation, payload log gating, session watcher kill switch, and the inactive-organization recovery path. ### Changed +- Signals that need exact counting are shipped through two channels: the log line (the debugging surface - the platform log pipeline samples it, including `error` level, so log-based counts are estimates) and an analytics event via `sendObservabilityEvent`, the same channel as the app's auth audit events (the measuring surface, exact counts, identifiers only - never emails or addresses). Double-shipped today: `organization-recovered`, `organization-unavailable`, `cart-address-sanitized`, `cart-address-field-rejected`, `cart-address-update-failed`. Delivery is fire-and-forget and a failure never affects the request. +- Every error log in the app (63 sites) now goes through `describeClientError` instead of passing the client error object through. That object carries the request it came from - `config.data` is the request body (addresses, profile data) and `config.url` can hold emails in Master Data `_where` query strings - so it must never be logged whole. The described object keeps what debugging needs: message and codes (email-redacted), HTTP status, the VTEX backend's own error contract (`vtexErrorCode`/`vtexErrorMessage`), the correlation ids backends answer with (`operationId`, `requestId`, `backend` - enough for the owning team to locate the request on their side), the request line with the query string stripped, and a bounded stack. - `setProfile` starts its user-independent lookups (sales channel list, B2B settings, app settings) before the user lookup instead of awaiting everything in one batch, hiding their latency behind the user and organization reads. - `getMarketingTags` and `generateClUser` no longer block the session transform response: both only feed fire-and-forget cart updates, and `generateClUser` had measured spikes near 1s. The CL profile lookup is also skipped entirely when there is no cart to update. - The sellers facets branch reuses the already-fetched cached app settings instead of issuing a second, uncached `getAppSettings` call. @@ -29,6 +31,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed +- The active B2B record is now resolved with a Master Data query filtered by `active=true`, instead of paginating through every record for the email and picking the active one in memory. For a shopper holding records in many organizations, the paginated scan can come back without the active row, and the code then fell back to `users[0]` - placing the shopper in an arbitrary organization, and failing the transform outright when that record pointed at an organization that is not active, which Session Manager surfaces as a generic 502 on login. The filtered query returns 0..1 records in a single call, so the unstable scan no longer runs. Covered by a regression test proven to fail against the old code. +- Organization status matches `b2b-organizations-graphql` again. Since the `setProfile` performance refactors replaced that app's GraphQL with a direct Master Data read, the status rule was reimplemented here as `!== 'inactive'` in three places, while the owning app's `checkOrganizationIsActive` answers `status === 'active'` - so an `on-hold` organization, a canonical status there, was shoppable here and rejected there. The rule now lives in a single module (`node/utils/organizationStatus.ts`) that mirrors the owner's vocabulary, and an unrecognized status fails closed and is reported (`setProfile.unknownOrganizationStatus`, `getUserOrganizationsData.unknownOrganizationStatus`) instead of diverging silently. +- The cart address is sanitized before it is sent to checkout. Checkout answers `CHK0040` for a fixed set of characters (`< > ? + " ; %`) and discards the entire `shippingData` attachment, so a single offending character made the cart silently keep its previous address - a common condition for integration-populated cost center addresses, whose `reference` field often carries a JSON blob with quotes in it. Which fields checkout validates is not documented (the error codes carry a `{0}` field placeholder), so it was established by probing the attachment field by field: of the 13 fields in the address contract, `addressId`, `addressType` and `addressQuery` accept the characters and the other 10 do not. Only the two **annotation** fields are cleaned - `reference` and `complement` describe *how* to deliver, never *where*, and they are where the offending values are typically found. The eight **location-bearing** fields (`street`, `number`, `city`, `state`, `neighborhood`, `receiverName`, `postalCode`, `country`) are never rewritten: the characters can be legitimate in them worldwide (Plus Codes are built around `+`; B2B receiver names carry `"` as an inch mark), so stripping one may point the delivery somewhere else. Those are reported as `CART_ADDRESS_FIELD_REJECTED` and left untouched, for the record to be fixed at the source - a corrupted location is worse than a rejected one. (The service enforces `< > ? + " ; %`; the docs additionally list `*` for `CHK0040`, which it does not reject.) Each rewrite is reported as `CART_ADDRESS_SANITIZED` (which fields, which characters, plus `orgId` / `costId` / `costCenterAddressId`) and an update that still fails is tagged `CART_ADDRESS_UPDATE_FAILED` with checkout's own error code, so the fix and what it prevents are both countable. **No address value is logged** at any level or setting: the sanitizer returns only metadata, and the checkout failure is reported field by field rather than by passing the client error object through - that object carries the request body, and therefore the address. +- Organization stickiness: absent an explicit selection, the session now keeps the organization *and cost center* it was already resolved to, instead of re-deriving them on every transform. `storefront-permissions.organization` and `costcenter` are declared as transform inputs (the same pattern the app already uses for `hash`) and the pair is resolved with a targeted, single-call lookup; matching on the organization alone would pick an arbitrary cost center for shoppers who hold several inside one organization. When only the cost center is gone, the session stays in the same organization (`getActiveUserByEmail-stickyCostCenterNoLongerAvailable`); when the shopper no longer has a record for the pinned organization at all, it is reported as `getActiveUserByEmail-stickyOrgNoLongerAvailable`. Without this, a shopper with many records could be resolved to a different organization on consecutive requests, which is what made switching cost center appear not to work. +- The session transform no longer writes to Master Data, under any circumstance. Which record is active is a decision that belongs to the shopper (through the organization switch) or to whoever manages the account's organizations - previously, when the selected organization was inactive, `setProfile` picked another organization and **persisted** that choice on its own, which turned an admin deactivating an organization into a silent, permanent relocation of its users. The recovery now only shapes the response: the session's own organization/cost center pin keeps consecutive responses on the same recovered organization (preferred over the list-based pick precisely because nothing is persisted), the shopper's stored selection is left intact for them or an admin to resolve, and each recovered session is reported as `setProfile.organizationRecovered` with the unusable and the recovered organization ids. If the shopper's original organization is reactivated, their stored selection takes effect again - instead of having been overwritten. +- When the user has no active record at all (records are created with `active: false`), the fallback used for a first login is deterministic and strictly read-only: it never writes an `active` flag, because an unvalidated record can point at an inactive or deleted organization and persisting it would make a bad selection permanent. The case is logged as `getActiveUserByEmail-noActiveRecord`. +- An organization that no longer exists no longer surfaces as an opaque failure: the lookup resolves it to "not found" (without caching the miss) and it follows the same recovery path as an inactive organization. When nothing can be recovered, `setProfile.organizationUnavailable` logs the shopper, the organization and whether it was `organizationNotFound` or `organizationInactive` - previously the sessions service reported only "App storefront-permissions failed", with nothing identifying who or why. - `setProfile` returned a 500 for any user whose organization is inactive but who has another active organization - the exact path meant to recover them. The recovery branch still unwrapped the response shape of the old GraphQL client (`.data.getOrganizationById`) after the Master Data client migration, resolving `organization` to `undefined` and throwing on `organization.name`. Covered by a regression test proven to fail against the old code. ### Removed diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index 6808bee1..1470816c 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -1,6 +1,6 @@ # Observability -All telemetry goes through `ctx.vtex.logger`, which ships to the platform log pipeline (Splunk / OpenSearch), tagged automatically with `account`, `workspace` and `app@version`. The design goal: **silent when healthy, loud exactly when something is slow or broken** — this route runs on every session transform across ~1k accounts, so one log line per request is not viable. +All telemetry goes through `ctx.vtex.logger`, which ships to the platform log pipeline, tagged automatically with `account`, `workspace` and `app@version`. The design goal: **silent when healthy, loud exactly when something is slow or broken** — this route runs on every session transform of every account with the B2B Suite installed, so one log line per request is not viable. ## Signals @@ -14,9 +14,31 @@ All telemetry goes through `ctx.vtex.logger`, which ships to the platform log pi | `staleFromVBase.readError` | `warn` | VBase read failed (request fell back to origin) | Recoverable per request, but a VBase outage shows up here. | | `setProfile.salesChannelDeferredToBinding` | `info` | Per request when the sales-channel deferral is active | If these *disappear* on an account that should have the flag on, the setting was lost (e.g. after a major version bump). | | `setProfile.regionDeferredToCheckoutSession` | `info` | Per request when the region handoff is active | Same reasoning as above. | +| `setProfile.cartAddressSanitized` (`code: CART_ADDRESS_SANITIZED`) | `warn` | The cost center address carried characters checkout rejects (`< > ? + " ; %`) in `reference` or `complement`, and they were stripped before sending | Counts how often the `CHK0040` rejection is being prevented. Only these two fields are ever rewritten: they describe *how* to deliver, never *where*, so stripping cannot move the delivery. Reports which fields were rewritten, which characters came out, and `orgId` / `costId` / `costCenterAddressId` to find the record. **Never reports address values**, at any level or setting. | +| `setProfile.cartAddressFieldRejected` (`code: CART_ADDRESS_FIELD_REJECTED`) | `error` | The cost center address has forbidden characters in a **location-bearing** field (`street`, `number`, `city`, `state`, `neighborhood`, `receiverName`, `postalCode`, `country`) | These are never rewritten: the characters can be legitimate there (Plus Codes are built around `+`; B2B receiver names carry `"` as an inch mark), so stripping one may point the delivery somewhere else — a corrupted location is worse than a rejected one. Checkout rejects the attachment and the cart keeps its previous address, so **the record has to be fixed at the source**. | +| `setProfile.updateOrderFormShippingError` (`code: CART_ADDRESS_UPDATE_FAILED`) | `error` | The cart address update failed even after sanitizing | Carries checkout's own `vtexErrorCode`, the HTTP `status`, and the fields that were rewritten. Compare its rate against `CART_ADDRESS_SANITIZED` to see what the sanitization did and did not fix. **The cart keeps its previous address**, so the shopper may be shipping to the wrong place. | +| `setProfile.unknownOrganizationStatus`, `getUserOrganizationsData.unknownOrganizationStatus` | `warn` | An organization status this app does not recognize | `b2b-organizations` owns the status vocabulary and this app mirrors it (see [Performance and caching](PERFORMANCE_AND_CACHING.md)); these fire when a new value is introduced upstream. Unknown statuses fail closed, so this is the signal that the two copies of the rule have diverged. | +| `setProfile.organizationRecovered` | `warn` | The shopper's stored selection points at an unusable organization and the session was served with another one | Nothing is written to resolve it — which record is active belongs to the shopper or the account admin — so every session for this shopper re-enters the recovery until one of them acts. Carries the unusable and the recovered organization ids; a sustained stream for one shopper means their record needs attention at the source. | +| `setProfile.organizationUnavailable` | `error` | The shopper's organization is missing or not active and nothing could be recovered | The transform fails, and Session Manager reports only a generic "App storefront-permissions failed" 502 — so this log is the only place that names the shopper, the organization and the `reason`/`status`. | +| `getActiveUserByEmail-noActiveRecord` | `warn` | The shopper has records but none is active (first login, or the selection was lost) | The resolution fell back read-only; reports the record and organization it used. | +| `getActiveUserByEmail-stickyOrgNoLongerAvailable`, `-stickyCostCenterNoLongerAvailable` | `warn` | The organization/cost center the session was pinned to is gone | Expected after an admin removes someone from an organization; a spike means something is deleting records. | | `setProfile.*Error` (updateSalesChannel, marketing data, shipping, CL profile, B2B settings...) | `error` | A fire-and-forget cart update failed | These never fail the response, so this is their only trace. | | `setProfile.body` / `setProfile.output` | `info` | Only when `logSessionPayloads` is enabled | Full session payload in/out. **Contains PII** (shopper email, organization data) and costs two `JSON.stringify` per request — enable per account only during an active investigation, then turn it off. | +## Counting vs debugging: two channels + +The platform log pipeline **samples** app logs deterministically (1 in 20 at the time of writing), and it does not spare the `error` level. Two consequences: + +- Any count built from log volume is an **estimate** (multiply by the sampling factor), and a rare event can be dropped entirely. +- The app cannot opt a log line out of that sampling. + +Signals that must be **counted**, not estimated, are therefore shipped twice: + +1. The **log line** (sampled) — the debugging surface, carries shopper context. +2. An **analytics event** via `sendObservabilityEvent` (`kind: b2b-storefront-permissions-`) — the measuring surface, exact counts, same channel as the app's auth audit events. Stricter privacy contract than the logs: identifiers only, never emails, names or addresses. + +Currently double-shipped: `organization-recovered`, `organization-unavailable`, `cart-address-sanitized`, `cart-address-field-rejected`, `cart-address-update-failed`. Events are fire-and-forget; a delivery failure is logged as `observabilityEvent.sendError` and never affects the request. + ## App settings (all tunable per account, no release needed) | Setting | Default | Purpose | @@ -38,11 +60,23 @@ Settings propagate within ~10 minutes (memory + VBase cache TTLs). ## Suggested alerts (configure in OpenSearch) -- **Failure rate:** count of `setProfile.timings` with `failed: true`, grouped by account — anything sustained is an incident (this signal caught a production 500 in the inactive-organization path). +- **Failure rate:** count of `setProfile.timings` with `failed: true`, grouped by account — anything sustained is an incident. This is the most valuable single alert: the transform's failures reach the shopper as a generic Session Manager 502 that names no cause. - **Slow-rate step change:** volume of `warn` timings per account vs its trailing baseline. - **Silent origin failure:** any `staleFromVBase.revalidateError` sustained for more than a few minutes. - **Lost feature flag:** `salesChannelDeferredToBinding` (or `regionDeferredToCheckoutSession`) log volume dropping to zero on an account where the flag should be on — the signature of settings lost on a major version bump. ## What deliberately does NOT log -Healthy requests. Per-call log lines were considered and rejected: at this volume they cost more than the calls they measure. The timer accumulates in memory (~20 `Date.now()` calls and one small object per request) and emits a single gated line. Platform-level request logs (status codes, unhandled errors) come from VTEX IO's router for free and are not duplicated here. +**Healthy requests.** Per-call log lines were considered and rejected: at this volume they cost more than the calls they measure. The timer accumulates in memory (~20 `Date.now()` calls and one small object per request) and emits a single gated line. Platform-level request logs (status codes, unhandled errors) come from VTEX IO's router for free and are not duplicated here. + +**Raw client errors — anywhere.** The HTTP client attaches the request to the error it throws: `config.data` is the request body (addresses, profile data) and `config.url` can carry emails in its query string (Master Data `_where` clauses). Passing `error` straight to the logger would carry all of that into the log pipeline. So **every** error log in this app goes through `describeClientError` (`node/utils/clientError.ts`), which keeps what debugging needs and drops the rest: + +- `message`, `code`, `status` — what failed and how (email-redacted). +- `vtexErrorCode` / `vtexErrorMessage` — the VTEX backend's own error contract. +- `operationId`, `requestId`, `backend` — the correlation ids VTEX backends answer with (`x-vtex-operation-id`, `x-request-id`, `x-vtex-janus-router-backend-app`, verified against live responses). Hand these to the owning team and they can locate the exact request on their side. +- `method` and `path` — with the query string stripped. +- `stack` — first lines only; code locations, never data. + +Rule for new code: a `.catch` never logs its error directly — always `error: describeClientError(error)`. There is a test asserting the described object carries no request body, no query string and no unredacted email. + +**Addresses, additionally.** The address sanitizer returns only the field name and the characters it removed, so a caller cannot log an address value by accident. diff --git a/docs/PERFORMANCE_AND_CACHING.md b/docs/PERFORMANCE_AND_CACHING.md index af9da255..9c300466 100644 --- a/docs/PERFORMANCE_AND_CACHING.md +++ b/docs/PERFORMANCE_AND_CACHING.md @@ -1,6 +1,6 @@ # Performance and caching in the session transform -`setProfile` (the `vtex.session` transform) runs on **every session creation and update, several times per storefront navigation, across every account with the B2B Suite installed**. Session Manager gives the transform a hard **2-second budget**; historically the transform chained enough serial external calls that cold pods regularly blew it. This document explains how the transform is structured today, how the caching works, and the rules to follow when changing it — so a future change does not silently reintroduce a regression. +`setProfile` (the `vtex.session` transform) runs on **every session creation and update, several times per storefront navigation, across every account with the B2B Suite installed**. Session Manager gives the transform a hard **2-second budget** — a budget that a chain of serial external calls on a cold pod can easily exceed, which is what the structure and the caching below are designed to prevent. This document explains how the transform is structured, how the caching works, and the rules to follow when changing it. ## Request flow @@ -32,7 +32,7 @@ All caches are built by `createCachedResource` (`node/services/cache.ts`), with ### Cache correctness rules -Every substantive bug found in review of this architecture was a variant of the same mistake: **letting the cache hold a state the origin never produced.** A cache entry is a claim — "this is what the origin returned for this key" — and each rule below protects that claim. Break one and the cache serves wrong responses repeatedly, for the full TTL, to every request that hits it. +Caching here has one failure mode worth internalizing, and every rule below is a variant of it: **letting the cache hold a state the origin never produced.** A cache entry is a claim — "this is what the origin returned for this key" — and each rule below protects that claim. Break one and the cache serves wrong responses repeatedly, for the full TTL, to every request that hits it. 1. **Never cache a failure.** A fetcher must rethrow errors, not swallow them into `undefined`/`null`. A swallowed failure gets stored by both layers, turning one transient Master Data blip into minutes of errors served from cache — after the origin has already recovered. Log at the fetcher if useful, but always rethrow; handle the failure *outside* the cached call so the next request retries. (Guarded by the "does not cache a failed organization lookup" test.) @@ -63,16 +63,37 @@ Corollary for reviews: when a change touches a fetcher or anything downstream of - **Sales channel list (6h):** effectively static account data. - **App settings (5+5min):** feature flags an operator may flip; worst-case propagation is roughly memory TTL + VBase TTL (~10 minutes), because the memory layer holds its entry for its TTL and then may read a stale VBase entry once before the background refresh lands. -- **Organization / cost center (60s/2min):** deliberately short — `organization.status === 'inactive'` blocks the user (`ForbiddenError`), so deactivating an organization must take effect within minutes. +- **Organization / cost center (60s/2min):** deliberately short — an organization that is not `active` blocks the user (`ForbiddenError`), so deactivating one must take effect within minutes. - **Session watcher (60s):** it is the operational kill switch; disabling it must bite quickly. - **Roles (60s):** authorization data. Role mutations write VBase but cannot invalidate other pods' memory caches, so this TTL is the upper bound on how long a revoked permission stays effective. -- **Active user:** the TTL is only a safety net. The cache key contains the session's `public.b2bCurrentCostCenter`, which `setCurrentOrganization` writes on every organization switch — so a switch changes the key and misses the cache immediately, regardless of TTL. The TTL covers changes that bypass that mutation (an admin editing a user's organizations, the inactive-org fallback). +- **Active user:** the TTL is only a safety net. The cache key contains the session's `public.b2bCurrentCostCenter`, which `setCurrentOrganization` writes on every organization switch — so a switch changes the key and misses the cache immediately, regardless of TTL. The TTL covers changes that bypass that mutation, such as an admin editing a user's organizations directly. - **`active-user-permissions` (60s, memory only):** the `checkPermissions` route receives only `app` + `email`, so there is no cost center to key on and no key-based invalidation. Short TTL bounds how long stale permissions can survive an organization switch; no VBase layer so nothing extends that window. ### Why the cost center cache is bounded by bytes Measured on a real account: organization documents span **187–480 bytes** (tight), while cost center documents span **~400 bytes to 29KB** (~70x, driven by the addresses list). A fixed entry count therefore makes the cost-center cache's memory footprint swing by 70x with the data. With a byte budget, `lru-cache` treats `max` as total serialized size: one unusually large document evicts others — and a document larger than the whole budget is *refused*, never stored. Note a parsed object costs roughly 2–3x its serialized length in heap; size budgets accordingly. +## Organization data: Master Data instead of b2b-organizations + +The organization document is read **straight from Master Data** (`masterDataExtended.getDocumentById('organizations', ...)`) rather than through `b2b-organizations-graphql`, which owns that entity. That substitution came from the `setProfile` performance refactors, and it is deliberate — measured against a real account: + +| Read | Samples | Median | +|---|---|---| +| Master Data document | 0.40 / 0.40 / 0.39 / 0.44 / 0.41 / 0.60s | **~0.40s** | +| `b2b-organizations` `getOrganizationById` | 0.99 / 1.19 / 1.80 / 1.87 / 2.24 / 2.35s | **~1.8s** | + +The extra app hop costs roughly **1.4s**, and individual samples exceeded **2.2s** — the transform's entire budget on their own. The variance is the disqualifying part, not the median. (Measured from a workstation, so both figures include the same client RTT; the delta is server-side. An in-cluster call would be faster in absolute terms, but the spread still rules it out for this path.) + +The trade-off is that the organization status rule then exists in two implementations. `b2b-organizations` owns the vocabulary (`ORGANIZATION_STATUSES`) and its `checkOrganizationIsActive` defines the semantics — only an `active` organization is usable — and this app mirrors it. + +Rules that follow from this: + +- **The status rule lives in exactly one module**, `node/utils/organizationStatus.ts`, which mirrors `b2b-organizations`' `ORGANIZATION_STATUSES` vocabulary and its `=== 'active'` semantics. Never write a status comparison at a call site. +- **Any change to this rule — here or in `b2b-organizations` — must be applied in both apps.** They are two copies of one rule; a change to one is a divergence until the other follows. +- **An unrecognized status fails closed and is logged** (`setProfile.unknownOrganizationStatus`, `getUserOrganizationsData.unknownOrganizationStatus`). Divergence cannot be prevented structurally without paying for the hop, so the fallback is to make it loud: a status introduced upstream shows up in the logs rather than silently landing in the "not usable" branch. + +**TODO:** extract this rule into a shared package consumed by both `storefront-permissions` and `b2b-organizations-graphql`, so there is one implementation instead of two copies kept in sync by convention. Requires agreement with the `b2b-organizations` owners, since nothing in the suite is published as a consumable library today. + ## Multi-tenancy A pod serves **more than one account** (the service route carries `{account}/{workspace}`), and the LRUs are module-level singletons shared by every request the pod handles. Two consequences: @@ -89,7 +110,7 @@ Entry bounds are **global budgets across all tenants on the pod**, not per accou - **`workers: 1` is intentional.** Each worker is a separate Node process with its own LRUs; two workers would duplicate every cache (double memory) and halve the hit rate. Scale with replicas, not workers. - **`timeout: 60` vs the 2s session budget:** Session Manager stops waiting at 2s, but this service also hosts the admin GraphQL routes (user/role listing, bulk operations) that legitimately need the headroom, so the global timeout stays at the suite standard. -## Known measurements (Aug 2026, kohlerqa) +## Known measurements (Aug 2026, B2B account with multi-organization users) | Scenario | Before | After | |---|---|---| diff --git a/docs/TESTING_COST_CENTER_ADDRESS_AND_REGION.md b/docs/TESTING_COST_CENTER_ADDRESS_AND_REGION.md index dc5901c1..fb6ebffe 100644 --- a/docs/TESTING_COST_CENTER_ADDRESS_AND_REGION.md +++ b/docs/TESTING_COST_CENTER_ADDRESS_AND_REGION.md @@ -40,7 +40,7 @@ Save. Changes can take up to the app settings cache TTL (see `COST_CENTER_ADDRES The session transform is called by the session backend. You can also call it directly for debugging (same body shape the session sends): - **URL:** `POST https://{workspace}--{account}.myvtex.com/_v/storefront-permissions/session/transform` -- **Headers:** Same as a normal storefront request (cookies/session as needed; the route is public but the handler uses the request body). +- **Headers:** Same as a normal storefront request (cookies/session as needed). - **Body (JSON):** Session-like payload, e.g.: ```json diff --git a/node/__tests__/checkoutAddress.test.ts b/node/__tests__/checkoutAddress.test.ts new file mode 100644 index 00000000..4d6b0de9 --- /dev/null +++ b/node/__tests__/checkoutAddress.test.ts @@ -0,0 +1,147 @@ +import { sanitizeAddressForCheckout } from '../utils/checkoutAddress' + +describe('sanitizeAddressForCheckout', () => { + it('leaves a clean address untouched and reports no changes', () => { + const address = { + addressId: 'addr1', + city: 'Springfield', + country: 'USA', + postalCode: '12345-678', + reference: null, + street: '100 Example Ave', + } + + const { address: result, sanitized } = sanitizeAddressForCheckout(address) + + expect(sanitized).toHaveLength(0) + expect(result).toEqual(address) + }) + + it('strips the characters checkout rejects from the reference blob', () => { + // The exact shape seen in production: quotes alone trigger CHK0040 and made + // checkout discard the whole shippingData attachment. + const reference = '{ "street2":"","street3":"","street4":"","default":""}' + + const { address, sanitized } = sanitizeAddressForCheckout({ reference }) + + expect(address.reference).toBe('{ street2:,street3:,street4:,default:}') + expect(sanitized).toEqual([{ field: 'reference', removed: ['"'] }]) + }) + + it('reports no address values, so nothing personal can reach a log', () => { + const { invalid, sanitized } = sanitizeAddressForCheckout({ + complement: 'Apt 4%', + street: 'Main St; 42', + }) + + // Whatever a caller logs from this must be safe by construction. + const serialized = JSON.stringify({ invalid, sanitized }) + + expect(serialized).not.toContain('Main St') + expect(serialized).not.toContain('Apt 4') + expect( + [...invalid, ...sanitized].every( + (entry) => Object.keys(entry).sort().join() === 'field,removed' + ) + ).toBe(true) + }) + + it('sanitizes every offending annotation field, not just the first', () => { + // Guards the global-regex trap: `.test()` on a /g pattern advances + // lastIndex, which would silently skip every other field. + const { address, sanitized } = sanitizeAddressForCheckout({ + complement: 'Suite 100%', + reference: 'has "quotes"', + }) + + expect(sanitized.map(({ field }) => field).sort()).toEqual([ + 'complement', + 'reference', + ]) + expect(address.complement).toBe('Suite 100') + expect(address.reference).toBe('has quotes') + }) + + it('never rewrites location-bearing fields, wherever in the world they point', () => { + // The forbidden characters can be legitimate there: Plus Codes - used as + // street addresses where streets have no numbering - are built around `+`, + // and B2B receiver names use `"` as an inch mark. Stripping them may point + // the delivery somewhere else, so these are reported, never rewritten. + const address = { + city: 'Nairobi;', + neighborhood: 'A+B', + number: '12+14', + receiverName: 'ACME 1/2" FITTINGS', + state: 'NBO%', + street: 'MQRG+59 Nairobi', + } + + const { address: result, invalid, sanitized } = + sanitizeAddressForCheckout(address) + + expect(sanitized).toHaveLength(0) + expect(result).toEqual(address) + expect(invalid.map(({ field }) => field).sort()).toEqual([ + 'city', + 'neighborhood', + 'number', + 'receiverName', + 'state', + 'street', + ]) + }) + + it('leaves the fields checkout does not validate alone', () => { + // Probing the shippingData attachment showed these three accept the + // characters, so rewriting them would be gratuitous. + const address = { + addressId: 'id?with+chars', + addressQuery: 'query"with;chars', + addressType: 'BillingAddress', + } + + const { address: result, invalid, sanitized } = + sanitizeAddressForCheckout(address) + + expect(sanitized).toHaveLength(0) + expect(invalid).toHaveLength(0) + expect(result.addressId).toBe('id?with+chars') + expect(result.addressQuery).toBe('query"with;chars') + }) + + it('reports postal code and country instead of rewriting them', () => { + // Both are validated by checkout, but they are codes: stripping a character + // makes them a different location, so the caller must surface the failure + // rather than ship to the wrong place. + const address = { country: 'US"A', postalCode: '12345%' } + + const { address: result, invalid, sanitized } = + sanitizeAddressForCheckout(address) + + expect(sanitized).toHaveLength(0) + expect(result.postalCode).toBe('12345%') + expect(result.country).toBe('US"A') + expect(invalid).toEqual([ + { field: 'country', removed: ['"'] }, + { field: 'postalCode', removed: ['%'] }, + ]) + }) + + it('does not mutate the input, which comes from a shared cache entry', () => { + const address = { reference: 'has "quotes"' } + + sanitizeAddressForCheckout(address) + + expect(address.reference).toBe('has "quotes"') + }) + + it('ignores non-string values', () => { + const { sanitized } = sanitizeAddressForCheckout({ + geoCoordinates: [1, 2], + number: 42, + reference: null, + }) + + expect(sanitized).toHaveLength(0) + }) +}) diff --git a/node/__tests__/clientError.test.ts b/node/__tests__/clientError.test.ts new file mode 100644 index 00000000..25dbc484 --- /dev/null +++ b/node/__tests__/clientError.test.ts @@ -0,0 +1,98 @@ +import { describeClientError } from '../utils/clientError' + +const axiosStyleError = () => ({ + code: 'ERR_BAD_REQUEST', + config: { + data: JSON.stringify({ + address: { postalCode: '99999', street: 'Private Road 9' }, + }), + method: 'post', + url: '/api/dataentities/b2b_users/search?_where=email=shopper@secret.com', + }, + message: 'Request failed with status code 400', + response: { + data: { + error: { code: 'CHK0040', message: 'O campo rua não aceita' }, + operationId: 'op-123', + }, + headers: { + 'x-request-id': 'req-456', + 'x-vtex-janus-router-backend-app': 'chk-v2.388.4-prd-598', + 'x-vtex-operation-id': 'op-123', + }, + status: 400, + }, + stack: 'AxiosError: Request failed with status code 400\n at settle (...)', +}) + +describe('describeClientError', () => { + it('extracts status, codes and the correlation ids VTEX backends answer with', () => { + const described: any = describeClientError(axiosStyleError()) + + expect(described).toMatchObject({ + backend: 'chk-v2.388.4-prd-598', + code: 'ERR_BAD_REQUEST', + message: 'Request failed with status code 400', + method: 'post', + operationId: 'op-123', + requestId: 'req-456', + status: 400, + vtexErrorCode: 'CHK0040', + }) + }) + + it('never carries the request body or the query string', () => { + const serialized = JSON.stringify(describeClientError(axiosStyleError())) + + // The body (config.data) is where addresses and profile data live. + expect(serialized).not.toContain('Private Road') + expect(serialized).not.toContain('99999') + // The query string is where Master Data lookups carry the email. + expect(serialized).not.toContain('shopper@secret.com') + expect(serialized).toContain('/api/dataentities/b2b_users/search') + }) + + it('redacts emails echoed into error messages', () => { + const described: any = describeClientError({ + message: 'user shopper@secret.com not found', + response: { data: { Message: 'no row for shopper@secret.com' } }, + }) + + expect(described.message).toBe('user not found') + expect(described.vtexErrorMessage).toBe('no row for ') + }) + + it('falls back to the error-code headers when the body has none', () => { + const described: any = describeClientError({ + response: { + data: 'Acesso negado', + headers: { 'x-vtex-error-code': 'CHK0040' }, + status: 403, + }, + }) + + expect(described.status).toBe(403) + expect(described.vtexErrorCode).toBe('CHK0040') + }) + + it('handles plain errors, strings and nothing at all', () => { + const plain: any = describeClientError(new Error('boom')) + + expect(plain.message).toBe('boom') + expect(plain.stack).toContain('Error: boom') + + expect(describeClientError('just text')).toEqual({ message: 'just text' }) + expect(describeClientError(null)).toBeNull() + expect(describeClientError(undefined)).toBeNull() + }) + + it('keeps the stack to a few lines of code locations', () => { + const longStack = ['Error: x', ...Array(30).fill(' at somewhere')].join( + '\n' + ) + + const described: any = describeClientError({ message: 'x', stack: longStack }) + + expect(described.stack.split('\n')).toHaveLength(5) + }) +}) diff --git a/node/__tests__/observabilityEvent.test.ts b/node/__tests__/observabilityEvent.test.ts new file mode 100644 index 00000000..f60c7d8c --- /dev/null +++ b/node/__tests__/observabilityEvent.test.ts @@ -0,0 +1,61 @@ +import { sendMetric } from '../clients/metrics' +import { sendObservabilityEvent } from '../utils/observabilityEvent' + +jest.mock('../clients/metrics', () => ({ + B2B_METRIC_NAME: 'b2b-suite-buyerorg-data', + sendMetric: jest.fn().mockResolvedValue(undefined), +})) + +const sendMetricMock = sendMetric as jest.Mock + +const makeCtx = (): any => ({ + vtex: { + account: 'acc', + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + workspace: 'master', + }, +}) + +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +describe('sendObservabilityEvent', () => { + it('ships the event through the analytics channel with tenant context', () => { + const ctx = makeCtx() + + sendObservabilityEvent(ctx, 'organization-recovered', { + recoveredOrgId: 'org2', + unusableOrgId: 'org1', + }) + + expect(sendMetricMock).toHaveBeenCalledWith({ + account: 'acc', + description: 'organization-recovered', + fields: { + recoveredOrgId: 'org2', + unusableOrgId: 'org1', + workspace: 'master', + }, + kind: 'b2b-storefront-permissions-organization-recovered', + name: 'b2b-suite-buyerorg-data', + }) + }) + + it('never throws and never rejects when the channel is down', async () => { + const ctx = makeCtx() + + sendMetricMock.mockRejectedValueOnce(new Error('ECONNRESET')) + + // Measurement must not affect the request: the failure is only logged. + expect(() => + sendObservabilityEvent(ctx, 'organization-recovered', {}) + ).not.toThrow() + + await flush() + + const reported = ctx.vtex.logger.warn.mock.calls.find( + (call: any[]) => call[0]?.message === 'observabilityEvent.sendError' + ) + + expect(reported?.[0]).toMatchObject({ event: 'organization-recovered' }) + }) +}) diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts index 4aaa6b64..6c42267d 100644 --- a/node/__tests__/setProfile.test.ts +++ b/node/__tests__/setProfile.test.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { json } from 'co-body' +import { sendMetric } from '../clients/metrics' +import { setActiveUserByOrganization } from '../resolvers/Mutations/Users' import { Routes } from '../resolvers/Routes' import { generateClUser, @@ -21,6 +23,13 @@ jest.mock('../resolvers/Mutations/Users', () => ({ setActiveUserByOrganization: jest.fn().mockResolvedValue(undefined), })) +// Observability events post to the analytics endpoint; tests must never do +// real network I/O, and the assertions below inspect the payloads. +jest.mock('../clients/metrics', () => ({ + B2B_METRIC_NAME: 'b2b-suite-buyerorg-data', + sendMetric: jest.fn().mockResolvedValue(undefined), +})) + process.env.VTEX_APP_ID = 'vtex.storefront-permissions@3.6.1' const jsonMock = json as jest.Mock @@ -32,22 +41,25 @@ let uniq = 0 interface Scenario { appSettings?: Record costCenterAddresses?: any[] + lossyScan?: boolean organization?: Record recoveredOrganization?: Record sessionWatcherActive?: boolean + userDocs?: any[] } const defaultAddress = { addressId: 'addr1', country: 'USA', geoCoordinates: null, - postalCode: '53012', + postalCode: '12345', } const makeCtx = (scenario: Scenario = {}) => { const { appSettings = {}, costCenterAddresses = [defaultAddress], + lossyScan = false, organization = { collections: null, name: 'Test Org', @@ -59,18 +71,19 @@ const makeCtx = (scenario: Scenario = {}) => { }, recoveredOrganization, sessionWatcherActive = true, + userDocs = [ + { + active: true, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + }, + ], } = scenario - const userDoc = { - active: true, - clId: 'cl1', - costId: 'cost1', - email: 'buyer@test.com', - id: 'u1', - name: 'Buyer', - orgId: 'org1', - } - const ctx: any = { clients: { apps: { getAppSettings: jest.fn().mockResolvedValue(appSettings) }, @@ -104,10 +117,45 @@ const makeCtx = (scenario: Scenario = {}) => { }), }, masterdata: { - searchDocumentsWithPaginationInfo: jest.fn().mockResolvedValue({ - data: [userDoc], - pagination: { page: 1, total: 1 }, - }), + createOrUpdatePartialDocument: jest + .fn() + .mockResolvedValue({ DocumentId: 'u1' }), + // Applies the `where` clause and the pagination window the way Master + // Data does, so a test can tell the active-only lookup apart from the + // full scan instead of getting the same canned list for both. + searchDocumentsWithPaginationInfo: jest + .fn() + .mockImplementation(({ where, pagination }: any) => { + const wantsActive = where?.includes('active=true') + const orgFilter = where?.match(/orgId=([^\s]+)/)?.[1] + const costFilter = where?.match(/costId=([^\s]+)/)?.[1] + + // `lossyScan` simulates a paginated scan that intermittently comes + // back without the active row, which can happen to users holding + // many records. The filtered and targeted lookups are unaffected, + // which is the whole point of using them. + let matching = userDocs + + if (wantsActive) { + matching = matching.filter((doc: any) => doc.active) + } else if (orgFilter) { + matching = matching.filter( + (doc: any) => + doc.orgId === orgFilter && + (!costFilter || doc.costId === costFilter) + ) + } else if (lossyScan) { + matching = matching.filter((doc: any) => !doc.active) + } + + const { page = 1, pageSize = 50 } = pagination ?? {} + const start = (page - 1) * pageSize + + return Promise.resolve({ + data: matching.slice(start, start + pageSize), + pagination: { page, total: matching.length }, + }) + }), }, organizations: { getB2BSettings: jest.fn().mockResolvedValue({ @@ -220,7 +268,7 @@ describe('setProfile', () => { expect(response.public.regionId.value).toBe('v2.TESTREGION') expect(ctx.clients.checkout.getRegionId).toHaveBeenCalledWith( 'USA', - '53012', + '12345', '1', null ) @@ -236,7 +284,7 @@ describe('setProfile', () => { expect(ctx.clients.checkout.getRegionId).not.toHaveBeenCalled() expect(response.public.regionId).toBeUndefined() - expect(response.public.postalCode.value).toBe('53012') + expect(response.public.postalCode.value).toBe('12345') expect(response.public.country.value).toBe('USA') }) @@ -282,22 +330,118 @@ describe('setProfile', () => { }, }) - // With the old `.data.getOrganizationById` unwrap this threw a TypeError - // and returned a 500; the fix must complete normally. The id-exact mock in - // makeCtx also fails this test if the lookup uses the user record id ('u2') - // instead of the organization id ('org2'). + // Unwrapping `.data.getOrganizationById` (the GraphQL client's response + // shape) would resolve to undefined and throw a TypeError here; this must + // complete normally. The id-exact mock in makeCtx also fails this test if + // the lookup uses the user record id ('u2') instead of the organization id + // ('org2'). const response = await run(ctx) expect(ctx.response.status).toBe(200) expect(getUserOrganizationsData).toHaveBeenCalled() - // The response must be stamped with the organization that was just - // activated, not the inactive one it arrived with. + // The response must be stamped with the recovered organization, not the + // inactive one the stored selection points at. expect(response['storefront-permissions'].organization.value).toBe('org2') expect(response['storefront-permissions'].costcenter.value).toBe('cost2') expect(ctx.clients.organizations.getCostCenterById).toHaveBeenCalledWith( 'cost2' ) + + // Recovery must never write: which record is active belongs to the + // shopper (organization switch) or to the account admin, so the transform + // only shapes this response and reports what it found. + expect(setActiveUserByOrganization).not.toHaveBeenCalled() + + const reported = ctx.vtex.logger.warn.mock.calls.find( + (call: any[]) => call[0]?.message === 'setProfile.organizationRecovered' + ) + + expect(reported?.[0]).toMatchObject({ + recoveredOrgId: 'org2', + unusableOrgId: 'org1', + }) + + // The log line is sampled by the platform pipeline; the exact count ships + // as an analytics event. Identifiers only on that channel - never email. + const event = (sendMetric as jest.Mock).mock.calls.find( + (call: any[]) => + call[0]?.kind === 'b2b-storefront-permissions-organization-recovered' + ) + + expect(event?.[0].fields).toMatchObject({ + recoveredOrgId: 'org2', + unusableOrgId: 'org1', + }) + expect(JSON.stringify(event?.[0])).not.toContain('buyer@test.com') + }) + + it('recovers to the organization the session already carries, not the first of the list', async () => { + // Without persistence the recovery reruns on every transform, and the + // list-based pick is not stable. The pair the session carries must win, + // so consecutive responses stay on the same organization. + const orgsDataMock = getUserOrganizationsData as jest.Mock + + // The list-based pick suggests org2/cost2... + orgsDataMock.mockResolvedValue({ + activeOrganization: { costId: 'cost2', id: 'u2', orgId: 'org2' }, + validCostCenterId: null, + }) + + const ctx = makeCtx({ + organization: { + collections: null, + name: 'Inactive Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'inactive', + tradeName: null, + }, + recoveredOrganization: { + collections: null, + name: 'Recovered Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + userDocs: [ + { + active: true, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + }, + { + active: false, + clId: 'cl2b', + costId: 'cost2b', + email: 'buyer@test.com', + id: 'u2b', + name: 'Buyer', + orgId: 'org2', + }, + ], + }) + + // ...but this session was already resolved to org2/cost2b. + const response = await run(ctx, { + ...makeBody(), + 'storefront-permissions': { + costcenter: { value: 'cost2b' }, + hash: { value: '' }, + organization: { value: 'org2' }, + }, + }) + + expect(response['storefront-permissions'].organization.value).toBe('org2') + expect(response['storefront-permissions'].costcenter.value).toBe('cost2b') + expect(setActiveUserByOrganization).not.toHaveBeenCalled() }) it('clears the cart on inactive-org recovery even when the session hash matched the old org', async () => { @@ -412,7 +556,11 @@ describe('setProfile', () => { const ctx = makeCtx() const lookups = ctx.clients.masterdata.searchDocumentsWithPaginationInfo - lookups.mockResolvedValueOnce({ data: [], pagination: { page: 1, total: 0 } }) + // Two empty answers: the active-only lookup, then the full scan it falls + // back to when no record is active. + lookups + .mockResolvedValueOnce({ data: [], pagination: { page: 1, total: 0 } }) + .mockResolvedValueOnce({ data: [], pagination: { page: 1, total: 0 } }) // First transform: user not provisioned yet, empty B2B session. const first = await run(ctx) @@ -426,6 +574,489 @@ describe('setProfile', () => { expect(second['storefront-permissions'].organization.value).toBe('org1') }) + it('resolves the active record with a single filtered lookup', async () => { + const ctx = makeCtx() + const lookups = ctx.clients.masterdata.searchDocumentsWithPaginationInfo + + await run(ctx) + + // Every lookup must carry the filter: an unfiltered scan would paginate + // through all of a multi-organization user's records. + for (const [args] of lookups.mock.calls) { + expect(args.where).toContain('active=true') + } + }) + + it('finds the active record even when the unfiltered scan loses it', async () => { + // A multi-record user whose paginated scan comes back without the active + // row: without the filter, the resolution falls back to `users[0]` and + // drops the shopper into an arbitrary organization. + // Filtering in Master Data returns the active record in a single call, + // so the lossy scan never runs. + const ctx = makeCtx({ + lossyScan: true, + recoveredOrganization: { + collections: null, + name: 'Active Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + userDocs: [ + { + active: false, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + }, + { + active: true, + clId: 'cl2', + costId: 'cost2', + email: 'buyer@test.com', + id: 'u2', + name: 'Buyer', + orgId: 'org2', + }, + ], + }) + + const response = await run(ctx) + + expect(response['storefront-permissions'].organization.value).toBe('org2') + expect(response['storefront-permissions'].costcenter.value).toBe('cost2') + }) + + it('falls back read-only when the user has no active record', async () => { + // Records are created with active=false, so a user who never picked an + // organization legitimately has none active. The fallback must be + // deterministic and must not write: the record is unvalidated and could + // point at an inactive or deleted organization, and persisting it would + // make a bad selection permanent. + const ctx = makeCtx({ + userDocs: [ + { + active: false, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + }, + ], + }) + + const response = await run(ctx) + + expect(response['storefront-permissions'].organization.value).toBe('org1') + expect( + ctx.clients.masterdata.createOrUpdatePartialDocument + ).not.toHaveBeenCalled() + + const reported = ctx.vtex.logger.warn.mock.calls.find( + (call: any[]) => + call[0]?.message === 'getActiveUserByEmail-noActiveRecord' + ) + + expect(reported?.[0]).toMatchObject({ + fallbackRecordId: 'u1', + totalRecords: 1, + }) + }) + + it('keeps the organization the session already carries when none is active', async () => { + // Without stickiness the resolution below is re-derived every transform and + // can drift for users with many records, making a cost center switch appear + // not to stick. 'org2' is not the record the plain scan would pick. + const ctx = makeCtx({ + recoveredOrganization: { + collections: null, + name: 'Sticky Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + userDocs: [ + { + active: false, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + }, + { + active: false, + clId: 'cl2', + costId: 'cost2', + email: 'buyer@test.com', + id: 'u2', + name: 'Buyer', + orgId: 'org2', + }, + ], + }) + + const response = await run(ctx, { + ...makeBody(), + 'storefront-permissions': { + hash: { value: '' }, + organization: { value: 'org2' }, + }, + }) + + expect(response['storefront-permissions'].organization.value).toBe('org2') + expect(response['storefront-permissions'].costcenter.value).toBe('cost2') + }) + + it('keeps the exact cost center when the organization has several', async () => { + // A shopper can hold one record per cost center inside the same + // organization, so pinning on the organization alone would pick an + // arbitrary cost center - the same drift, one level down. + const ctx = makeCtx({ + recoveredOrganization: { + collections: null, + name: 'Sticky Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + userDocs: [ + { + active: false, + clId: 'clA', + costId: 'costA', + email: 'buyer@test.com', + id: 'uA', + name: 'Buyer', + orgId: 'org2', + }, + { + active: false, + clId: 'clB', + costId: 'costB', + email: 'buyer@test.com', + id: 'uB', + name: 'Buyer', + orgId: 'org2', + }, + ], + }) + + const response = await run(ctx, { + ...makeBody(), + 'storefront-permissions': { + costcenter: { value: 'costB' }, + hash: { value: '' }, + organization: { value: 'org2' }, + }, + }) + + expect(response['storefront-permissions'].organization.value).toBe('org2') + expect(response['storefront-permissions'].costcenter.value).toBe('costB') + }) + + it('stays in the organization when only the pinned cost center is gone', async () => { + const ctx = makeCtx({ + recoveredOrganization: { + collections: null, + name: 'Sticky Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + userDocs: [ + { + active: false, + clId: 'clA', + costId: 'costA', + email: 'buyer@test.com', + id: 'uA', + name: 'Buyer', + orgId: 'org2', + }, + ], + }) + + const response = await run(ctx, { + ...makeBody(), + 'storefront-permissions': { + costcenter: { value: 'costGone' }, + hash: { value: '' }, + organization: { value: 'org2' }, + }, + }) + + expect(response['storefront-permissions'].organization.value).toBe('org2') + expect(response['storefront-permissions'].costcenter.value).toBe('costA') + + const reported = ctx.vtex.logger.warn.mock.calls.find( + (call: any[]) => + call[0]?.message === + 'getActiveUserByEmail-stickyCostCenterNoLongerAvailable' + ) + + expect(reported?.[0]).toMatchObject({ stickyCostId: 'costGone' }) + }) + + it('falls back and reports when the session organization is no longer available', async () => { + // The shopper was removed from the organization the session was pinned to. + const ctx = makeCtx({ + userDocs: [ + { + active: false, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + }, + ], + }) + + const response = await run(ctx, { + ...makeBody(), + 'storefront-permissions': { + hash: { value: '' }, + organization: { value: 'orgGone' }, + }, + }) + + expect(response['storefront-permissions'].organization.value).toBe('org1') + + const reported = ctx.vtex.logger.warn.mock.calls.find( + (call: any[]) => + call[0]?.message === 'getActiveUserByEmail-stickyOrgNoLongerAvailable' + ) + + expect(reported?.[0]).toMatchObject({ stickyOrgId: 'orgGone' }) + }) + + it('logs an explicit reason when the organization no longer exists', async () => { + const orgsDataMock = getUserOrganizationsData as jest.Mock + + orgsDataMock.mockResolvedValue({ + activeOrganization: null, + validCostCenterId: null, + }) + + // No document for 'org1': the active record points at a deleted org. + const ctx = makeCtx({ organization: undefined }) + + ctx.clients.masterDataExtended.getDocumentById.mockResolvedValue(undefined) + + await expect(run(ctx)).rejects.toThrow('Organization not found') + + const reported = ctx.vtex.logger.error.mock.calls.find( + (call: any[]) => call[0]?.message === 'setProfile.organizationUnavailable' + ) + + // The sessions service turns this into a generic 502, so the log is the + // only place that says which shopper and which organization failed. + expect(reported?.[0]).toMatchObject({ + email: 'buyer@test.com', + organizationId: 'org1', + reason: 'organizationNotFound', + }) + }) + + it('treats an on-hold organization as unusable, like b2b-organizations does', async () => { + // b2b-organizations' own checkOrganizationIsActive answers + // `status === 'active'`, so 'on-hold' must not be shoppable here either. + // The previous `!== 'inactive'` check let it through. + const orgsDataMock = getUserOrganizationsData as jest.Mock + + orgsDataMock.mockResolvedValue({ + activeOrganization: { costId: 'cost2', id: 'u2', orgId: 'org2' }, + validCostCenterId: null, + }) + + const ctx = makeCtx({ + organization: { + collections: null, + name: 'On Hold Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'on-hold', + tradeName: null, + }, + recoveredOrganization: { + collections: null, + name: 'Recovered Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + }) + + const response = await run(ctx) + + expect(getUserOrganizationsData).toHaveBeenCalled() + expect(response['storefront-permissions'].organization.value).toBe('org2') + }) + + it('reports a status it does not know about instead of failing silently', async () => { + const orgsDataMock = getUserOrganizationsData as jest.Mock + + orgsDataMock.mockResolvedValue({ + activeOrganization: null, + validCostCenterId: null, + }) + + const ctx = makeCtx({ + organization: { + collections: null, + name: 'Odd Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'suspended-by-finance', + tradeName: null, + }, + }) + + // Fails closed: an unrecognized status is not shoppable. + await expect(run(ctx)).rejects.toThrow() + + const reported = ctx.vtex.logger.warn.mock.calls.find( + (call: any[]) => + call[0]?.message === 'setProfile.unknownOrganizationStatus' + ) + + expect(reported?.[0]).toMatchObject({ status: 'suspended-by-finance' }) + }) + + it('sanitizes the cart address and reports what it removed', async () => { + const ctx = makeCtx({ + costCenterAddresses: [ + { + ...defaultAddress, + reference: '{ "street2":"","street3":""}', + }, + ], + }) + + await run(ctx) + + const sent = + ctx.clients.checkout.updateOrderFormShipping.mock.calls[0]?.[1] + + // Checkout must receive the cleaned value, otherwise it answers CHK0040 and + // discards the whole attachment, leaving the previous address on the cart. + expect(sent.address.reference).toBe('{ street2:,street3:}') + + const reported = ctx.vtex.logger.warn.mock.calls.find( + (call: any[]) => call[0]?.code === 'CART_ADDRESS_SANITIZED' + ) + + expect(reported?.[0]).toMatchObject({ + costCenterAddressId: 'addr1', + fields: [{ field: 'reference', removed: ['"'] }], + message: 'setProfile.cartAddressSanitized', + orgId: 'org1', + }) + + // No address values at any level: they are personal data and this runs on + // every session transform. + expect(JSON.stringify(reported?.[0])).not.toContain('street2') + }) + + it('reports a rejected postal code instead of shipping to a different place', async () => { + const ctx = makeCtx({ + costCenterAddresses: [{ ...defaultAddress, postalCode: '12345%' }], + }) + + await run(ctx) + + const reported = ctx.vtex.logger.error.mock.calls.find( + (call: any[]) => call[0]?.code === 'CART_ADDRESS_FIELD_REJECTED' + ) + + expect(reported?.[0]).toMatchObject({ + fields: [{ field: 'postalCode', removed: ['%'] }], + orgId: 'org1', + }) + + // Never rewritten: a stripped postal code is a different location. + const sent = + ctx.clients.checkout.updateOrderFormShipping.mock.calls[0]?.[1] + + expect(sent.address.postalCode).toBe('12345%') + }) + + it('never logs address values, even with payload logging on', async () => { + const ctx = makeCtx({ + appSettings: { logSessionPayloads: true }, + costCenterAddresses: [ + { + ...defaultAddress, + reference: '{ "street2":"CONFIDENTIAL"}', + street: 'Private Road 9', + }, + ], + }) + + await run(ctx) + + const sanitizedLog = ctx.vtex.logger.warn.mock.calls.find( + (call: any[]) => call[0]?.code === 'CART_ADDRESS_SANITIZED' + ) + + expect(JSON.stringify(sanitizedLog?.[0])).not.toContain('CONFIDENTIAL') + }) + + it('tags a cart address update that still fails after sanitizing', async () => { + const ctx = makeCtx({ + costCenterAddresses: [ + { ...defaultAddress, reference: 'has "quotes"' }, + ], + }) + + // Shaped like a real axios rejection: `config.data` carries the request + // body, which is the shopper's address. Logging the error object whole + // would carry it into the logs, so the described object must not. + ctx.clients.checkout.updateOrderFormShipping.mockRejectedValue({ + config: { + data: JSON.stringify({ address: { street: 'Private Road 9' } }), + }, + message: 'Request failed with status code 400', + response: { + data: { error: { code: 'CHK0040', message: 'reference field' } }, + status: 400, + }, + }) + + await run(ctx) + + const reported = ctx.vtex.logger.error.mock.calls.find( + (call: any[]) => call[0]?.code === 'CART_ADDRESS_UPDATE_FAILED' + ) + + expect(reported?.[0]).toMatchObject({ + error: { status: 400, vtexErrorCode: 'CHK0040' }, + sanitizedFields: ['reference'], + }) + + expect(JSON.stringify(reported?.[0])).not.toContain('Private Road') + }) + it('keeps full payload logging off unless logSessionPayloads is enabled', async () => { const quiet = makeCtx() diff --git a/node/clients/metrics.ts b/node/clients/metrics.ts index df2d8ecd..74358b4c 100644 --- a/node/clients/metrics.ts +++ b/node/clients/metrics.ts @@ -12,5 +12,9 @@ export interface Metric { } export const sendMetric = async (metric: Metric) => { - await axios.post(ANALYTICS_URL, metric) + // Every caller is fire-and-forget, so a slow analytics endpoint never blocks + // a request - but without a timeout each pending POST would hold a socket + // and its promise for as long as the endpoint hangs. Bounding it keeps the + // worst case at a few seconds of idle socket, not unbounded accumulation. + await axios.post(ANALYTICS_URL, metric, { timeout: 3000 }) } diff --git a/node/directives/helper.ts b/node/directives/helper.ts index cc79e9ee..09a818bd 100644 --- a/node/directives/helper.ts +++ b/node/directives/helper.ts @@ -1,4 +1,5 @@ import { isUserPartOfBuyerOrg } from '../resolvers/Queries/Users' +import { describeClientError } from '../utils/clientError' import { LICENSE_MANAGER_ROLES, B2B_LM_PRODUCT_CODE } from '../utils/constants' export const validateAdminToken = async ( @@ -71,7 +72,7 @@ export const validateAdminToken = async ( // noop so we leave hasValidAdminToken as false logger.warn({ message: 'Error validating admin token', - err, + err: describeClientError(err), }) } } @@ -158,7 +159,7 @@ export const validateApiToken = async ( // noop so we leave hasValidApiToken as false logger.warn({ message: 'Error validating API token', - err, + err: describeClientError(err), }) } } @@ -211,7 +212,7 @@ export const validateStoreToken = async ( // noop so we leave hasValidStoreToken as false logger.warn({ message: 'Error validating store token:', - err, + err: describeClientError(err), }) } } diff --git a/node/metrics/auth.ts b/node/metrics/auth.ts index 4815a341..464d69a3 100644 --- a/node/metrics/auth.ts +++ b/node/metrics/auth.ts @@ -2,6 +2,7 @@ import type { Logger } from '@vtex/api/lib/service/logger/logger' import type { Metric } from '../clients/metrics' import { B2B_METRIC_NAME, sendMetric } from '../clients/metrics' +import { describeClientError } from '../utils/clientError' export interface AuthAuditMetric { operation: string @@ -42,7 +43,7 @@ const sendAuthMetric = async (logger: Logger, authMetric: AuthMetric) => { await sendMetric(authMetric) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: `Error to send metrics from auth metric`, }) } diff --git a/node/metrics/session.ts b/node/metrics/session.ts index 4e2ebe29..1e67356e 100644 --- a/node/metrics/session.ts +++ b/node/metrics/session.ts @@ -2,6 +2,7 @@ import type { Logger } from '@vtex/api/lib/service/logger/logger' import type { Metric } from '../clients/metrics' import { B2B_METRIC_NAME, sendMetric } from '../clients/metrics' +import { describeClientError } from '../utils/clientError' export interface SessionAuditMetric { operation: string @@ -37,7 +38,7 @@ const sendSessionMetric = async ( await sendMetric(sessionMetric) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: `Error to send metrics from session metric`, }) } diff --git a/node/resolvers/Mutations/Profiles.ts b/node/resolvers/Mutations/Profiles.ts index 768d39fb..9da576e9 100644 --- a/node/resolvers/Mutations/Profiles.ts +++ b/node/resolvers/Mutations/Profiles.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { currentSchema } from '../../utils' +import { describeClientError } from '../../utils/clientError' import { getProfileByRole } from '../Queries/Profiles' const config: any = currentSchema('b2b_profiles') @@ -31,7 +32,7 @@ export const saveProfile = async (_: any, params: any, ctx: Context) => { } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.saveProfile-error', }) @@ -51,7 +52,7 @@ export const deleteProfile = async (_: any, params: any, ctx: Context) => { return { status: 'success', message: '' } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.deleteProfile-error', }) diff --git a/node/resolvers/Mutations/Roles.ts b/node/resolvers/Mutations/Roles.ts index b83fe323..5e2763a7 100644 --- a/node/resolvers/Mutations/Roles.ts +++ b/node/resolvers/Mutations/Roles.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { currentRoleNames, Slugify, toHash } from '../../utils' +import { describeClientError } from '../../utils/clientError' import { ROLES_VBASE_ID } from '../../utils/constants' import { groupByRole } from '../Queries/Features' import { searchRoles } from '../Queries/Roles' @@ -38,7 +39,7 @@ export const saveRole = async (_: any, params: any, ctx: Context) => { return { status: 'success', message: '', id: data.slug } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Roles.saveRole-error', }) @@ -138,7 +139,7 @@ export const deleteRole = async (_: any, params: any, ctx: Context) => { return { status: 'success', message: '', id: params.id } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Roles.deleteRole-error', }) diff --git a/node/resolvers/Mutations/Settings.ts b/node/resolvers/Mutations/Settings.ts index e815331f..e7eb93c5 100644 --- a/node/resolvers/Mutations/Settings.ts +++ b/node/resolvers/Mutations/Settings.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { describeClientError } from '../../utils/clientError' import { getAppId } from '../Queries/Settings' export const sessionWatcher = async (_: any, params: any, ctx: Context) => { @@ -22,7 +23,7 @@ export const sessionWatcher = async (_: any, params: any, ctx: Context) => { .then(() => true) .catch((error) => { logger.error({ - error, + error: describeClientError(error), message: 'sessionWatcher.saveSessionWatcherError', }) diff --git a/node/resolvers/Mutations/Users.ts b/node/resolvers/Mutations/Users.ts index e532c03a..2f7541cc 100644 --- a/node/resolvers/Mutations/Users.ts +++ b/node/resolvers/Mutations/Users.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { currentSchema } from '../../utils' +import { describeClientError } from '../../utils/clientError' import { CUSTOMER_SCHEMA_NAME } from '../../utils/constants' import type { ChangeTeamParams } from '../../utils/metrics/changeTeam' import { sendChangeTeamMetric } from '../../utils/metrics/changeTeam' @@ -34,7 +35,7 @@ const setChangeSession = async ( await session.updateSession(publicKey, value, [], sessionCookie) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'setChangeSession.error', attempt: countRetry, }) @@ -284,7 +285,7 @@ export const addUser = async (_: any, params: any, ctx: Context) => { return { status: 'success', message: '', id: cId } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'addUser.error', }) @@ -313,7 +314,7 @@ export const updateUser = async (_: any, params: any, ctx: Context) => { return { status: 'success', message: '', id: params.clId } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'updateUser.error', }) @@ -353,7 +354,7 @@ export const deleteUserProfile = async (_: any, params: any, ctx: Context) => { } logger.error({ - error, + error: describeClientError(error), message: 'deleteUserProfile.error', }) @@ -369,7 +370,7 @@ export const deleteUserProfile = async (_: any, params: any, ctx: Context) => { } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'deleteUserProfile.error', }) @@ -394,7 +395,7 @@ export const deleteUser = async (_: any, params: any, ctx: Context) => { return { status: 'success', message: '' } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'deleteUser.error', }) @@ -420,7 +421,7 @@ export const impersonateUser = async (_: any, params: any, ctx: Context) => { return { status: 'success', message: '' } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'impersonateUser.error', }) @@ -487,7 +488,7 @@ export const addOrganizationToUser = async ( ) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'addOrganizationToUser.error', }) @@ -541,7 +542,7 @@ export const addCostCenterToUser = async ( ) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'addCostCenterToUser.error', }) @@ -571,7 +572,7 @@ export const setActiveUserByOrganization = async ( }) .catch((error: any) => { logger.error({ - error, + error: describeClientError(error), message: 'orders-getSession-error', }) @@ -627,7 +628,7 @@ export const setActiveUserByOrganization = async ( await Promise.all(promises) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'setActiveUserById.error', }) } @@ -666,7 +667,7 @@ export const setCurrentOrganization = async ( 'This organization/cost center is not allowed to this current user' logger.error({ - error, + error: describeClientError(error), message: 'updateCurrentOrganization.error', }) @@ -705,7 +706,7 @@ export const setCurrentOrganization = async ( return { status: 'success', message: '' } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'updateCurrentOrganization.error', }) @@ -743,7 +744,7 @@ export const setCurrentPriceTable = async ( const error = 'User not properly authenticated with organization context' logger.error({ - error, + error: describeClientError(error), message: 'setCurrentPriceTable.error.noOrgContext', }) @@ -764,7 +765,7 @@ export const setCurrentPriceTable = async ( const error = 'Price table not allowed for this organization' logger.error({ - error, + error: describeClientError(error), message: 'setCurrentPriceTable.error.invalidPriceTable', priceTable, orgId, @@ -783,7 +784,7 @@ export const setCurrentPriceTable = async ( return { status: 'success', message: '' } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'setCurrentPriceTable.error', }) @@ -816,7 +817,7 @@ export const ignoreB2BSessionData = async ( return { status: 'success', message: '' } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'removeB2BSessionData.error', }) diff --git a/node/resolvers/Queries/Profiles.ts b/node/resolvers/Queries/Profiles.ts index 0ed99a81..55e392e8 100644 --- a/node/resolvers/Queries/Profiles.ts +++ b/node/resolvers/Queries/Profiles.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { currentSchema } from '../../utils' +import { describeClientError } from '../../utils/clientError' import { listRoles } from './Roles' const config: any = currentSchema('b2b_profiles') @@ -20,7 +21,7 @@ export const getProfile = async (_: any, params: any, ctx: Context) => { }) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.getProfile-error', }) @@ -48,7 +49,7 @@ export const getProfileByRole = async (_: any, params: any, ctx: Context) => { return profile } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.getProfileByRole-error', }) @@ -82,7 +83,7 @@ export const listProfiles = async (_: any, __: any, ctx: Context) => { }) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.listProfiles-error', }) diff --git a/node/resolvers/Queries/Roles.ts b/node/resolvers/Queries/Roles.ts index 48a43370..5e73531a 100644 --- a/node/resolvers/Queries/Roles.ts +++ b/node/resolvers/Queries/Roles.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getCachedRoles } from '../../services/rolesCache' import { currentRoleNames, currentSchema } from '../../utils' +import { describeClientError } from '../../utils/clientError' import { ROLES_VBASE_ID } from '../../utils/constants' import { getUserByRole } from './Users' @@ -80,7 +81,7 @@ export const getRole = async (_: any, params: any, ctx: Context) => { return role } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Roles.getRole', }) diff --git a/node/resolvers/Queries/Settings.ts b/node/resolvers/Queries/Settings.ts index 7da5ba1d..02922efb 100644 --- a/node/resolvers/Queries/Settings.ts +++ b/node/resolvers/Queries/Settings.ts @@ -1,5 +1,6 @@ import schemas from '../../mdSchema' import { toHash } from '../../utils' +import { describeClientError } from '../../utils/clientError' import { syncRoles } from '../Mutations/Roles' import type { ErrorResponse } from '../Routes/utils' @@ -65,7 +66,7 @@ export const getAppSettings = async (_: any, __: any, ctx: Context) => { .catch((error) => { if (error.response.status !== 304) { logger.error({ - error, + error: describeClientError(error), message: 'getAppSettings-error', }) @@ -99,7 +100,7 @@ export const getSessionWatcher = async (_: any, __: any, ctx: Context) => { return settings?.sessionWatcher?.active ?? true } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'getSessionWatcher.getSessionWatcherError', }) diff --git a/node/resolvers/Queries/Users.ts b/node/resolvers/Queries/Users.ts index 3d4d5dc5..511cccd5 100644 --- a/node/resolvers/Queries/Users.ts +++ b/node/resolvers/Queries/Users.ts @@ -4,6 +4,7 @@ import { removeVersionFromAppId } from '@vtex/api' import { getCachedAppSettings } from '../../services/appSettingsCache' import type { GetOrganizationsPaginatedByEmailResponse } from '../../typings/custom' import { currentSchema } from '../../utils' +import { describeClientError } from '../../utils/clientError' import { CUSTOMER_REQUIRED_FIELDS, CUSTOMER_SCHEMA_NAME, @@ -122,7 +123,7 @@ export const getAllUsers = async ({ return users } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.getAllUsersByEmail-error', }) throw new Error(error) @@ -135,7 +136,7 @@ export const getAllUsersByEmail = async (_: any, params: any, ctx: Context) => { vtex: { logger }, } = ctx - const { email, orgId, costId } = params + const { email, orgId, costId, active } = params let where = `email=${email}` @@ -147,6 +148,10 @@ export const getAllUsersByEmail = async (_: any, params: any, ctx: Context) => { where += ` AND costId=${costId}` } + if (active !== undefined) { + where += ` AND active=${active}` + } + return getAllUsers({ masterdata, logger, where }) } @@ -160,10 +165,113 @@ export const getActiveUserByEmail = async ( } = ctx try { - const users = await getAllUsersByEmail(null, params, ctx) - const activeUser = users.find((user: any) => user.active) + // Fast path: setActiveUserByOrganization keeps at most one record active + // per email, so filtering in Master Data returns 0..1 records in a single + // call. Scanning every record for the email instead (3+ pages for + // multi-organization users) is both slower and unsafe: whenever the + // paginated scan misses the active row, a `users[0]` fallback lands the + // shopper in an arbitrary organization. + const activeUsers = await getAllUsersByEmail( + null, + { ...params, active: true }, + ctx + ) + + if (activeUsers.length > 1) { + // Data corruption (e.g. a race between two organization switches). + // getAllUsers sorts by id, so picking the first is still deterministic. + logger.warn({ + email: params.email, + message: 'getActiveUserByEmail-multipleActiveRecords', + recordIds: activeUsers.map((user: any) => user.id), + }) + } - const userFound = activeUser || users[0] + let userFound = activeUsers[0] + + // No explicit selection, but the session already carries an organization + // from a previous transform: keep it. Without this the resolution below is + // re-derived on every transform. For a shopper holding many records the + // scan below is not stable, so re-deriving lets the organization change + // between requests: stickiness breaks and switching cost center looks like + // it does nothing. Targeted lookup, so it stays a single Master Data call. + if (!userFound && params.stickyOrgId) { + // Match the exact organization *and* cost center the session was + // resolved to. A shopper can hold several records in the same + // organization, one per cost center, so matching on the organization + // alone would pick an arbitrary one and reintroduce the same drift one + // level down. + const stickyUsers = await getAllUsersByEmail( + null, + { + email: params.email, + orgId: params.stickyOrgId, + ...(params.stickyCostId ? { costId: params.stickyCostId } : {}), + }, + ctx + ) + + userFound = stickyUsers[0] + + if (!userFound && params.stickyCostId) { + // The cost center is gone, but the organization itself may still be + // available to this shopper: stay in it rather than falling all the + // way back to an unrelated organization. + const sameOrgUsers = await getAllUsersByEmail( + null, + { email: params.email, orgId: params.stickyOrgId }, + ctx + ) + + userFound = sameOrgUsers[0] + + if (userFound) { + logger.warn({ + email: params.email, + message: 'getActiveUserByEmail-stickyCostCenterNoLongerAvailable', + stickyCostId: params.stickyCostId, + stickyOrgId: params.stickyOrgId, + }) + } + } + + if (!userFound) { + // The user no longer has a record for the organization the session was + // pinned to (removed from it, or the record was deleted). + logger.warn({ + email: params.email, + message: 'getActiveUserByEmail-stickyOrgNoLongerAvailable', + stickyOrgId: params.stickyOrgId, + }) + } + } + + if (!userFound) { + // Legitimate state: records are created with active=false, so a user who + // never picked an organization has none active. Fall back to the first + // record of the id-sorted scan, which is deterministic across pods and + // requests - an unordered pick would land the shopper in a different + // organization from one request to the next. + // + // Read-only on purpose: which record is active belongs to the shopper + // (organization switch) or the account admin, never to this resolution. + // setProfile validates the organization downstream and, when it has to + // serve a different one, does so for the response only - the session pin + // keeps that choice stable across requests without writing anything. + const allUsers = await getAllUsersByEmail(null, params, ctx) + + userFound = allUsers[0] + + if (userFound) { + logger.warn({ + email: params.email, + fallbackOrgId: userFound.orgId, + fallbackRecordId: userFound.id, + message: 'getActiveUserByEmail-noActiveRecord', + totalRecords: allUsers.length, + }) + } + } if (!userFound) { logger.warn({ @@ -179,7 +287,7 @@ export const getActiveUserByEmail = async ( } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: `getActiveUserByEmail-error`, }) @@ -231,7 +339,7 @@ export const getUserById = async (_: any, params: any, ctx: Context) => { return cl ?? null } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.getUserById-error', }) @@ -290,7 +398,7 @@ export const getB2BUserById = async (_: any, params: any, ctx: Context) => { return user } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.getUserById-error', }) @@ -349,7 +457,7 @@ export const getUser = async (_: any, params: any, ctx: Context) => { } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.getUser-error', }) @@ -384,7 +492,7 @@ export const getUserByRole = async (_: any, params: any, ctx: Context) => { }) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.getUserByRole-error', }) @@ -455,7 +563,7 @@ export const listUsers = async ( return res } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.listUsers-error', }) @@ -544,7 +652,7 @@ export const listUsersPaginated = async ( }) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'Profiles.listUsersPaginated-error', }) @@ -698,7 +806,7 @@ export const checkUserPermission = async ( // Only impersonation sessions need the setting, so regular sessions never // pay for reading it (cached for 5 minutes when they do). const appSettings = await getCachedAppSettings(ctx).catch((error) => { - logger.warn({ error, message: 'checkUserPermission-getAppSettingsError' }) + logger.warn({ error: describeClientError(error), message: 'checkUserPermission-getAppSettingsError' }) return {} as Record }) @@ -838,7 +946,7 @@ export const getUsersByEmail = async (_: any, params: any, ctx: Context) => { }) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: `getUsersByEmail-error`, }) throw new Error(error) @@ -868,7 +976,7 @@ export const getOrganizationsByEmail = async ( ) } catch (error) { logger.error({ - error, + error: describeClientError(error), message: `getOrganizationsByEmail-error`, }) @@ -907,7 +1015,7 @@ export const getOrganizationsPaginatedByEmail = async ( return data } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'getOrganizationsPaginatedByEmail-error', }) @@ -963,7 +1071,7 @@ export const getUserByEmailOrgIdAndCostId = async ( return (user[0] as UserByEmail) || null } catch (error) { logger.error({ - error, + error: describeClientError(error), message: `getUsersByEmail-error`, }) throw new Error(error) diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index 9fbc2da9..03bfbb19 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -16,10 +16,21 @@ import { getCachedRegionId } from '../../services/regionCache' import { getCachedSalesChannel } from '../../services/salesChannelCache' import { getCachedSessionWatcher } from '../../services/sessionWatcherCache' import { toHash } from '../../utils' +import { sanitizeAddressForCheckout } from '../../utils/checkoutAddress' +import { describeClientError } from '../../utils/clientError' +import { sendObservabilityEvent } from '../../utils/observabilityEvent' +import { + isKnownOrganizationStatus, + isOrganizationUsable, +} from '../../utils/organizationStatus' import { createTimer, getTimer } from '../../utils/requestTimings' -import { getUser, setActiveUserByOrganization } from '../Mutations/Users' +import { getUser } from '../Mutations/Users' import { getRole } from '../Queries/Roles' -import { getActiveUserByEmail, getB2BUserById } from '../Queries/Users' +import { + getActiveUserByEmail, + getAllUsersByEmail, + getB2BUserById, +} from '../Queries/Users' import { generateClUser, getUserOrganizationsData } from './utils' export const Routes = { @@ -229,6 +240,25 @@ export const Routes = { */ const currentCostCenter = body?.public?.b2bCurrentCostCenter?.value ?? null + /** + * The organization this session was already resolved to. Declared as an + * input alongside `hash` (which this transform likewise reads back from its + * own namespace) so that, absent an explicit selection, the session keeps + * the organization it already had instead of re-deriving it - the lookup is + * not stable for users with many organizations, and re-deriving made the + * organization drift between requests. + */ + const stickyOrgId = + body?.['storefront-permissions']?.organization?.value || null + + /** + * Paired with the organization above: a shopper can hold several records in + * the same organization, one per cost center, so the organization alone + * does not identify which record the session was resolved to. + */ + const stickyCostId = + body?.['storefront-permissions']?.costcenter?.value || null + if (ignoreB2B) { ctx.response.body = response ctx.response.status = 200 @@ -298,7 +328,7 @@ export const Routes = { getCachedB2BSettings(ctx, () => organizations.getB2BSettings()) ) .catch((error) => { - logger.error({ error, message: 'setProfile.getB2BSettings' }) + logger.error({ error: describeClientError(error), message: 'setProfile.getB2BSettings' }) return null }) @@ -320,7 +350,11 @@ export const Routes = { // (which would produce empty B2B sessions until the TTL expired), and // handle the failure outside the cached call so it is retried next time. const fetchActiveUser = async () => { - const activeUser: any = await getActiveUserByEmail(null, { email }, ctx) + const activeUser: any = await getActiveUserByEmail( + null, + { email, stickyCostId, stickyOrgId }, + ctx + ) if (activeUser?.status === 'error') { throw activeUser.message @@ -344,7 +378,15 @@ export const Routes = { const cachedUser: any = await timer .track( 'getActiveUserByEmail', - getCachedActiveUserByEmail(ctx, email, currentCostCenter, fetchActiveUser) + getCachedActiveUserByEmail( + ctx, + email, + currentCostCenter, + stickyOrgId && stickyCostId + ? `${stickyOrgId}:${stickyCostId}` + : stickyOrgId, + fetchActiveUser + ) ) .catch((error) => { if (!error?.userNotFound) { @@ -387,11 +429,26 @@ export const Routes = { 'collections', 'sellers', ]) + .then((document: any) => { + // Master Data answers a missing document with an empty result + // rather than an error. Throwing keeps the miss out of both cache + // layers (see getOrganizationOrNull, which turns it into null). + if (!document) { + const notFound: any = new Error('organizationNotFound') + + notFound.organizationNotFound = true + throw notFound + } + + return document + }) .catch((error) => { - logger.error({ - error, - message: 'setProfile.graphqlGetOrganizationById', - }) + if (!error?.organizationNotFound) { + logger.error({ + error: describeClientError(error), + message: 'setProfile.graphqlGetOrganizationById', + }) + } // Rethrow so a transient Master Data failure fails only this // request. Swallowing it here would make the cache store an empty @@ -402,6 +459,22 @@ export const Routes = { ) } + // A 404 means the organization no longer exists (deleted, or an id that + // was never valid), so the caller can adopt another organization or report + // it explicitly. Deliberately outside the cached fetcher above: caching a + // "not found" would pin the shopper to the error for the whole TTL. Any + // other failure still rejects, so it is retried on the next request. + const getOrganizationOrNull = async (orgId: any): Promise => + getOrganization(orgId).catch((error: any) => { + const status = error?.response?.status ?? error?.status + + if (error?.organizationNotFound || status === 404) { + return null + } + + throw error + }) + // Reassigned by the inactive-organization fallback below. const hash = toHash(`${user.orgId}|${user.costId}`) let hashChanged = body?.['storefront-permissions']?.hash?.value !== hash @@ -421,7 +494,7 @@ export const Routes = { const marketingTagsPromise = organizations .getMarketingTags(user.costId) .catch((error) => { - logger.error({ error, message: 'setProfile.getMarketingTags' }) + logger.error({ error: describeClientError(error), message: 'setProfile.getMarketingTags' }) return null }) @@ -435,7 +508,7 @@ export const Routes = { // below adopts a different cost center. const [organizationResponse, initialCostCenterResponse] = await Promise.all( [ - timer.track('getOrganization', getOrganization(user.orgId)), + timer.track('getOrganization', getOrganizationOrNull(user.orgId)), timer.track( 'getCostCenterById', getCachedCostCenter(ctx, String(resolvedCostId), () => @@ -482,7 +555,7 @@ export const Routes = { user.costId = usersData?.costId ?? user.costId } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'setProfile.graphqlGetOrganizationById', }) } @@ -496,15 +569,31 @@ export const Routes = { costCenterResponse.data?.getCostCenterById ?? {} ).every((value) => value === null) - const organizationInactive = organization.status === 'inactive' - const needsOrgData = organizationInactive || costCenterInvalid + // Null means the lookup 404'd: the record points at an organization that + // no longer exists. Both states are unusable and share the same recovery. + const organizationMissing = !organization + const organizationInactive = + !organizationMissing && !isOrganizationUsable(organization?.status) + const organizationUnusable = organizationMissing || organizationInactive + + if ( + !organizationMissing && + !isKnownOrganizationStatus(organization?.status) + ) { + logger.warn({ + message: 'setProfile.unknownOrganizationStatus', + organizationId: user.orgId, + status: organization?.status, + }) + } + const needsOrgData = organizationUnusable || costCenterInvalid if (needsOrgData) { userOrgsData = await timer.track( 'getUserOrganizationsData', getUserOrganizationsData(email, ctx).catch((error) => { logger.error({ - error, + error: describeClientError(error), message: 'setProfile.getUserOrganizationsData', }) @@ -518,18 +607,67 @@ export const Routes = { user.costId = userOrgsData.validCostCenterId } - // Handle inactive organization - if (organizationInactive) { - const validOrganization = userOrgsData?.activeOrganization + // Handle an organization that is inactive or no longer exists. + // + // The recovery only shapes THIS response - it never writes. Which record + // is active is a decision that belongs to the shopper (organization + // switch) or to whoever manages the account's organizations; the session + // transform rewriting it on its own turned an admin deactivating an + // organization into a silent, permanent relocation of its users. + if (organizationUnusable) { + let validOrganization = userOrgsData?.activeOrganization + + // Resolve the replacement before committing to it: the fallback record + // can itself point at an organization that no longer exists, and + // adopting a null one would only move the failure a few lines down. + let fallbackOrganization = validOrganization + ? await getOrganizationOrNull(validOrganization.orgId) + : null + + // Prefer the pair this session already carries. Since nothing is + // persisted, the list-based pick above could land on a different + // organization on the next transform; honoring the session pin keeps + // consecutive responses stable without touching Master Data. + if (stickyOrgId && String(stickyOrgId) !== String(user.orgId)) { + const stickyOrganization = await getOrganizationOrNull(stickyOrgId) + + if ( + stickyOrganization && + isOrganizationUsable(stickyOrganization.status) + ) { + const [stickyRecord] = await getAllUsersByEmail( + null, + { + email, + orgId: stickyOrgId, + ...(stickyCostId ? { costId: stickyCostId } : {}), + }, + ctx + ) + + if (stickyRecord) { + validOrganization = { + costId: stickyRecord.costId, + id: stickyRecord.id, + orgId: stickyRecord.orgId, + } + fallbackOrganization = stickyOrganization + } + } + } + + if (fallbackOrganization) { + // Captured before user.orgId is reassigned below, so the log can say + // which organization the shopper's stored selection points at. + const unusableOrgId = user.orgId - if (validOrganization) { // getOrganization reads Master Data directly, so it returns the document - // itself. Unwrapping `.data.getOrganizationById` here is left over from - // when this went through the b2b-organizations GraphQL client, and it - // resolved to undefined, throwing on the `organization.name` access - // below. Note `validOrganization.id` is the b2b_users record id, not the - // organization id, so the lookup must use `orgId`. - organization = await getOrganization(validOrganization.orgId) + // itself - do not unwrap `.data.getOrganizationById` (the GraphQL + // client's response shape): that resolves to undefined and throws on + // the `organization.name` access below. Note `validOrganization.id` is + // the b2b_users record id, not the organization id, so the lookup must + // use `orgId`. + organization = fallbackOrganization // Adopt the fallback locally as well, so this response is stamped with // the organization we just activated instead of the inactive one: the @@ -560,28 +698,64 @@ export const Routes = { ) ) - await setActiveUserByOrganization( - null, - { - costId: validOrganization.costId, - email, - orgId: validOrganization.orgId, - userId: validOrganization.id, - }, - ctx - ).catch((error) => { - logger.warn({ - error, - message: 'setProfile.setActiveUserByOrganizationError', - }) + // Visible on purpose: the shopper's stored selection points at an + // unusable organization, nothing is written to fix it (that decision + // belongs to the shopper or the account admin), so every session for + // them re-enters this recovery until one of those two acts. This log + // is the signal that the record needs attention at the source. + logger.warn({ + email, + message: 'setProfile.organizationRecovered', + recoveredCostId: validOrganization.costId, + recoveredOrgId: validOrganization.orgId, + unusableOrgId, + }) + + // The log above is sampled by the platform pipeline; the event is the + // exact count. No email here: identifiers only on this channel. + sendObservabilityEvent(ctx, 'organization-recovered', { + recoveredCostId: String(validOrganization.costId), + recoveredOrgId: String(validOrganization.orgId), + unusableOrgId: String(unusableOrgId), }) } else { - logger.warn({ - message: `setProfile-organizationInactive`, - organizationData: organization, + // Nothing to recover to. This still fails the transform, which the + // sessions service reports as a generic "App storefront-permissions + // failed" 502 with no detail, so this log is the only place that + // explains why a specific shopper cannot log in. Keep it explicit and + // searchable: filter by message to find every affected shopper. + sendObservabilityEvent(ctx, 'organization-unavailable', { + organizationId: String(user.orgId), + reason: organizationMissing + ? 'organizationNotFound' + : 'organizationNotActive', + status: organizationMissing ? null : organization?.status ?? null, + }) + + logger.error({ + email, + message: 'setProfile.organizationUnavailable', organizationId: user.orgId, + reason: organizationMissing + ? 'organizationNotFound' + : 'organizationNotActive', + // Only an 'active' organization is usable, so this covers 'inactive', + // 'on-hold' and anything b2b-organizations adds later. + status: organizationMissing ? null : organization?.status, }) - throw new ForbiddenError('Organization is inactive') + + timer.meta.extra = { + ...timer.meta.extra, + organizationUnavailable: organizationMissing + ? 'organizationNotFound' + : 'organizationInactive', + } + + throw new ForbiddenError( + organizationMissing + ? 'Organization not found' + : 'Organization is inactive' + ) } } @@ -779,7 +953,7 @@ export const Routes = { .updateSalesChannel(orderFormId, salesChannel) .catch((error) => { logger.error({ - error, + error: describeClientError(error), message: 'setProfile.updateSalesChannel', }) }) @@ -814,7 +988,7 @@ export const Routes = { } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'setProfile.clearCart', }) } @@ -888,7 +1062,7 @@ export const Routes = { } } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'setProfile.getRegionId', }) } @@ -921,27 +1095,101 @@ export const Routes = { }) .catch((error) => { logger.error({ - error, + error: describeClientError(error), message: 'setProfile.updateOrderFormMarketingDataError', }) }) ) if (!usePublicPostalCodeForRegion) { + // Checkout rejects a set of characters with CHK0040 and discards the + // whole attachment, which would make the cart silently keep its + // previous address. + const { + address: checkoutAddress, + invalid, + sanitized, + } = sanitizeAddressForCheckout(address) + + if (invalid.length) { + // Location-bearing fields are never rewritten: a stripped character + // can point the delivery somewhere else (Plus Codes are built around + // `+`, B2B receiver names carry `"` as an inch mark). Checkout will + // reject the attachment and the cart keeps its previous address - + // this log is the only notice that the record needs fixing at the + // source. + logger.error({ + code: 'CART_ADDRESS_FIELD_REJECTED', + costCenterAddressId: address.addressId, + costId: user.costId, + fields: invalid, + message: 'setProfile.cartAddressFieldRejected', + orgId: user.orgId, + }) + + sendObservabilityEvent(ctx, 'cart-address-field-rejected', { + costCenterAddressId: String(address.addressId ?? ''), + costId: String(user.costId), + fields: invalid.map(({ field }) => field).join(','), + orgId: String(user.orgId), + }) + } + + if (sanitized.length) { + // No address values here, at any log level: they are personal data + // and this runs on every session transform. Which field was rewritten + // and which characters came out is enough to count the occurrences + // and find the offending record through the ids below. + logger.warn({ + code: 'CART_ADDRESS_SANITIZED', + costCenterAddressId: address.addressId, + costId: user.costId, + fields: sanitized, + message: 'setProfile.cartAddressSanitized', + orgId: user.orgId, + }) + + sendObservabilityEvent(ctx, 'cart-address-sanitized', { + costCenterAddressId: String(address.addressId ?? ''), + costId: String(user.costId), + fields: sanitized.map(({ field }) => field).join(','), + orgId: String(user.orgId), + }) + } + promises.push( checkout .updateOrderFormShipping(orderFormId, { address: { - ...address, - geoCoordinates: address.geoCoordinates ?? [], + ...checkoutAddress, + geoCoordinates: checkoutAddress.geoCoordinates ?? [], isDisposable: true, }, clearAddressIfPostalCodeNotFound: false, }) .catch((error) => { + // Still failing after sanitizing: the cart keeps its previous + // address, so the shopper may be shipping to the wrong place. + // `code` makes the remaining failures countable next to the + // CART_ADDRESS_SANITIZED events they were supposed to prevent. + // + const described = describeClientError(error) + logger.error({ - error, + code: 'CART_ADDRESS_UPDATE_FAILED', + costCenterAddressId: address.addressId, + costId: user.costId, + error: described, message: 'setProfile.updateOrderFormShippingError', + orgId: user.orgId, + sanitizedFields: sanitized.map(({ field }) => field), + }) + + sendObservabilityEvent(ctx, 'cart-address-update-failed', { + costId: String(user.costId), + orgId: String(user.orgId), + status: (described as any)?.status ?? null, + vtexErrorCode: (described as any)?.vtexErrorCode ?? null, }) }) ) @@ -1001,7 +1249,7 @@ export const Routes = { }) .catch((error) => { logger.error({ - error, + error: describeClientError(error), message: 'setProfile.updateOrderFormProfileError', }) }) @@ -1018,10 +1266,10 @@ export const Routes = { orgId: user.orgId, } - // Off by default: this used to run on every session transform, which on a - // route this hot means two JSON.stringify calls per request plus a log line - // carrying the whole session in and out, including the shopper's email and - // organization data. Enable it per account only while debugging. + // Off by default: on a route this hot, unconditional payload logging means + // two JSON.stringify calls per request plus a log line carrying the whole + // session in and out, including the shopper's email and organization data. + // Enable it per account only while debugging. if ((appSettings as any)?.logSessionPayloads) { logger.info({ 'setProfile.body': JSON.stringify(body), diff --git a/node/resolvers/Routes/utils/index.ts b/node/resolvers/Routes/utils/index.ts index 8d4c94f7..f1349984 100644 --- a/node/resolvers/Routes/utils/index.ts +++ b/node/resolvers/Routes/utils/index.ts @@ -1,4 +1,9 @@ import type { GetOrganizationByEmailBase } from '../../../typings/custom' +import { describeClientError } from '../../../utils/clientError' +import { + isKnownOrganizationStatus, + isOrganizationUsable, +} from '../../../utils/organizationStatus' import { getUserById } from '../../Queries/Users' // Simple in-memory cache with TTL @@ -229,8 +234,8 @@ export const getUserOrganizationsData = async ( (org) => org.costCenterName !== null ) - const hasActiveOrg = firstPageData.some( - (org) => org.organizationStatus !== 'inactive' + const hasActiveOrg = firstPageData.some((org) => + isOrganizationUsable(org.organizationStatus) ) // Only fetch more pages if we're missing data @@ -246,7 +251,7 @@ export const getUserOrganizationsData = async ( organizations .getOrganizationsPaginatedByEmail(email, page, 200) .catch((error) => { - logger.warn({ error, message: 'Failed to fetch page', page }) + logger.warn({ error: describeClientError(error), message: 'Failed to fetch page', page }) return null }) @@ -269,10 +274,29 @@ export const getUserOrganizationsData = async ( (org) => org.costCenterName !== null ) - const activeOrg = allOrganizations.find( - (org) => org.organizationStatus !== 'inactive' + const activeOrg = allOrganizations.find((org) => + isOrganizationUsable(org.organizationStatus) ) + // Surface a status this app does not know about, so a value introduced by + // b2b-organizations shows up here instead of silently being treated as + // unusable. + const unknownStatuses = Array.from( + new Set( + allOrganizations + .map((org) => org.organizationStatus) + .filter((status) => !isKnownOrganizationStatus(status)) + ) + ) + + if (unknownStatuses.length) { + logger.warn({ + email, + message: 'getUserOrganizationsData.unknownOrganizationStatus', + statuses: unknownStatuses, + }) + } + const result = { validCostCenterId: validCostCenterOrg?.costId || null, activeOrganization: activeOrg || null, @@ -300,7 +324,7 @@ export const getUserOrganizationsData = async ( return result } catch (error) { logger.error({ - error, + error: describeClientError(error), message: 'getUserOrganizationsData.error', email, }) diff --git a/node/services/activeUserCache.ts b/node/services/activeUserCache.ts index 0a5e1518..24bb8a80 100644 --- a/node/services/activeUserCache.ts +++ b/node/services/activeUserCache.ts @@ -10,11 +10,14 @@ import { createCachedResource } from './cache' * measured spiking well past a second, and the session transform runs several * times per navigation for the same email. * - * The key is `email + b2bCurrentCostCenter`. That second part is written into the - * session by `setCurrentOrganization` whenever the user switches organization, so - * a switch produces a different key and therefore a miss, rather than serving the - * previous organization from cache. Because invalidation is exact, both layers - * can be used and the TTL only has to cover changes that bypass that mutation. + * The key is `email + b2bCurrentCostCenter + sticky`. `b2bCurrentCostCenter` is + * written into the session by `setCurrentOrganization` whenever the user switches + * organization, so a switch produces a different key and therefore a miss, rather + * than serving the previous organization from cache. `sticky` is the + * organization/cost center pair the session already carries, which participates in + * resolving the user, so two sessions pinned to different pairs must not share an + * entry. Because invalidation is exact, both layers can be used and the TTL only + * has to cover changes that bypass those flows. */ const cachedActiveUser = createCachedResource('active-user', { // Small payloads (~400B), one per shopper. @@ -52,12 +55,19 @@ export const getCachedActiveUserByEmail = async ( ctx: Context, email: string, currentCostCenter: string | null, + sticky: string | null, fetcher: () => Promise ): Promise => - cachedActiveUser(ctx, `${email}|${currentCostCenter ?? 'default'}`, fetcher, { - memoryTtlMs: - configuredTtlMsByTenant.get(tenantKey(ctx)) ?? ACTIVE_USER_CACHE_TTL_IN_MS, - }) + cachedActiveUser( + ctx, + `${email}|${currentCostCenter ?? 'default'}|${sticky ?? 'none'}`, + fetcher, + { + memoryTtlMs: + configuredTtlMsByTenant.get(tenantKey(ctx)) ?? + ACTIVE_USER_CACHE_TTL_IN_MS, + } + ) /** * Variant for permission checks (checkPermissions route). Those requests carry diff --git a/node/utils/checkoutAddress.ts b/node/utils/checkoutAddress.ts new file mode 100644 index 00000000..14cfb171 --- /dev/null +++ b/node/utils/checkoutAddress.ts @@ -0,0 +1,142 @@ +/** + * Checkout rejects a fixed set of characters in address text fields, answering + * `CHK0040` ("The reference field not accept the characters: < > ? + " ; %") and + * failing the whole `shippingData` attachment. + * + * Cost center addresses populated by integrations routinely carry them: the + * `reference` field is often a JSON blob (`{ "street2":"", ... }`) whose quotes + * alone are enough. Because the attachment fails as a unit, a single offending + * character makes the cart silently keep its previous address, so switching + * cost center appears to do nothing. + * + * Stripping is only applied where it cannot move the delivery (see the two + * field lists below); everywhere else the offending field is reported and the + * rejection stands, because a corrupted location is worse than a rejected one. + */ +export const CHECKOUT_FORBIDDEN_ADDRESS_CHARACTERS = [ + '<', + '>', + '?', + '+', + '"', + ';', + '%', +] + +const FORBIDDEN_PATTERN = /[<>?+";%]/g + +/** + * Which fields checkout actually validates was established by probing the + * `shippingData` attachment field by field, because it is not documented (the + * error codes reference a `{0}` placeholder). Of the 13 fields in the address + * contract, 10 are validated and 3 are not: `addressId`, `addressType` and + * `addressQuery` accept the characters. + * + * Note the service enforces `< > ? + " ; %`. The docs additionally list `*` for + * `CHK0040`; the service does not reject it. + */ + +/** + * Annotation fields: they describe *how* to deliver, never *where*. Stripping a + * forbidden character from them cannot move the delivery, so rewriting is safe + * - and `reference` is where the production failures actually come from + * (integration-populated JSON blobs whose quotes alone trigger CHK0040). + */ +const SANITIZED_FIELDS = ['complement', 'reference'] + +/** + * Location-bearing fields. VTEX serves addresses from every country, and the + * forbidden characters can be *meaningful* in them: Plus Codes - used as street + * addresses where streets have no numbering - are built around `+`, and B2B + * receiver names use `"` as an inch mark. A location field with a character + * stripped out may point somewhere else, so silently rewriting one would turn + * a rejected cart into one shipping to the wrong place. These are reported + * instead, and the caller surfaces the failure so the record is fixed at the + * source. + */ +const REPORTED_FIELDS = [ + 'city', + 'country', + 'neighborhood', + 'number', + 'postalCode', + 'receiverName', + 'state', + 'street', +] + +/** + * Deliberately carries no address values, only which field was rewritten and + * which characters came out. Address data is personal data and this runs on the + * session transform, so there is no sampling rate at which logging the values + * would be acceptable — the metadata is enough to count and diagnose. + */ +export interface SanitizedAddressField { + field: string + removed: string[] +} + +export interface SanitizeAddressResult { + address: T + /** + * Location-bearing fields that carry forbidden characters and were + * deliberately left as they are. Checkout will reject the attachment, so the + * caller must report this rather than let it fail silently. + */ + invalid: SanitizedAddressField[] + sanitized: SanitizedAddressField[] +} + +const forbiddenCharactersIn = (value: string) => + CHECKOUT_FORBIDDEN_ADDRESS_CHARACTERS.filter((char) => value.includes(char)) + +/** + * Returns a copy of the address with the forbidden characters removed, plus a + * description of every change so the caller can report what it had to rewrite. + * The input is never mutated (it comes from a shared cache entry). + */ +export const sanitizeAddressForCheckout = >( + address: T +): SanitizeAddressResult => { + const sanitized: SanitizedAddressField[] = [] + const result: Record = { ...address } + + for (const field of SANITIZED_FIELDS) { + const value = result[field] + + if (typeof value !== 'string') { + continue + } + + // Compare against the replacement instead of calling `.test()`: the pattern + // is global, and `.test()` on a global regex advances `lastIndex`, which + // would make it skip every other field it is asked about. + const to = value.replace(FORBIDDEN_PATTERN, '') + + if (to === value) { + continue + } + + sanitized.push({ field, removed: forbiddenCharactersIn(value) }) + + result[field] = to + } + + const invalid: SanitizedAddressField[] = [] + + for (const field of REPORTED_FIELDS) { + const value = result[field] + + if (typeof value !== 'string') { + continue + } + + const removed = forbiddenCharactersIn(value) + + if (removed.length) { + invalid.push({ field, removed }) + } + } + + return { address: result as T, invalid, sanitized } +} diff --git a/node/utils/clientError.ts b/node/utils/clientError.ts new file mode 100644 index 00000000..a9e7fd6c --- /dev/null +++ b/node/utils/clientError.ts @@ -0,0 +1,67 @@ +/** + * What gets logged when an outbound call fails. + * + * Never log a client error object whole: the HTTP client attaches the request + * to it, so `error.config.data` is the request body (addresses, profile data) + * and `error.config.url` can carry emails in its query string (Master Data + * `_where` clauses). Passing `error` straight to the logger would carry all of + * that into the log pipeline. + * + * This extracts what debugging actually needs and nothing else: + * + * - `message` / `code` / `status` — what failed and how. + * - `vtexErrorCode` / `vtexErrorMessage` — the VTEX backend's own error + * contract (`{ error: { code, message } }`, or Master Data's `{ Message }`). + * - `operationId` / `requestId` / `backend` — correlation ids most VTEX + * backends answer with (`x-vtex-operation-id`, `x-request-id`, + * `x-vtex-janus-router-backend-app`), verified against live responses. Hand + * these to the owning team and they can find the request on their side. + * - `method` / `path` — the request line, with the query string stripped. + * - `stack` — first lines only; code locations, never data. + * + * Free-text fields go through email redaction, since backend error messages + * sometimes echo input back. + */ + +const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g + +const redact = (value: unknown): string | null => + typeof value === 'string' + ? value.replace(EMAIL_PATTERN, '') + : null + +const stripQuery = (url: unknown): string | null => + typeof url === 'string' ? url.split('?')[0] : null + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const describeClientError = (error: any) => { + if (error === null || error === undefined) { + return null + } + + if (typeof error === 'string') { + return { message: redact(error) } + } + + const headers = error?.response?.headers ?? {} + const body = error?.response?.data + const bodyError = body?.error + + return { + backend: headers['x-vtex-janus-router-backend-app'] ?? null, + code: error?.code ?? null, + message: redact(error?.message), + method: error?.config?.method ?? null, + operationId: + headers['x-vtex-operation-id'] ?? body?.operationId ?? null, + path: stripQuery(error?.config?.url), + requestId: headers['x-request-id'] ?? null, + stack: + typeof error?.stack === 'string' + ? error.stack.split('\n').slice(0, 5).join('\n') + : null, + status: error?.response?.status ?? null, + vtexErrorCode: bodyError?.code ?? headers['x-vtex-error-code'] ?? null, + vtexErrorMessage: redact(bodyError?.message ?? body?.Message), + } +} diff --git a/node/utils/constants.ts b/node/utils/constants.ts index cfe4a37a..933e80d5 100644 --- a/node/utils/constants.ts +++ b/node/utils/constants.ts @@ -50,8 +50,8 @@ export const ROLES_CACHE_TTL_IN_MS = 60 * 1000 * lets the TTL be generous. * * The TTL is only a safety net for changes that do *not* go through that - * mutation (an admin editing the user's organizations, or the inactive-org - * fallback in setProfile). Set `sessionUserCacheTtlMs` to 0 to disable. + * mutation, such as an admin editing the user's organizations directly. + * Set `sessionUserCacheTtlMs` to 0 to disable. */ export const ACTIVE_USER_CACHE_TTL_IN_MS = 5 * 60 * 1000 export const ACTIVE_USER_CACHE_TTL_IN_MINUTES = 5 diff --git a/node/utils/observabilityEvent.ts b/node/utils/observabilityEvent.ts new file mode 100644 index 00000000..64b2da1d --- /dev/null +++ b/node/utils/observabilityEvent.ts @@ -0,0 +1,54 @@ +import type { Metric } from '../clients/metrics' +import { B2B_METRIC_NAME, sendMetric } from '../clients/metrics' +import { describeClientError } from './clientError' + +/** + * The platform log pipeline samples `io_vtex_logs` deterministically (1 in 20 + * at the time of writing), and it does not spare the `error` level - so any + * count built from logs is an estimate, and a rare-but-important event can be + * dropped entirely. Signals that must be *counted*, not estimated, are shipped + * additionally through the analytics events channel, which this app already + * uses for its auth audit events and which does not go through that sampling. + * + * The log line remains the debugging surface (it carries the shopper context); + * the event is the measuring surface. Both are emitted, they are not + * alternatives. + * + * Privacy contract, stricter than the logs: identifiers only (organization, + * cost center, address id, error codes). Never emails, names, addresses or + * payloads - analytics events live in a different store with its own retention + * and audience. + */ + +interface ObservabilityEvent extends Metric { + readonly fields: Record +} + +export const sendObservabilityEvent = ( + ctx: Context, + name: string, + fields: Record +) => { + const { + vtex: { account, logger, workspace }, + } = ctx + + const event: ObservabilityEvent = { + account, + description: name, + fields: { ...fields, workspace }, + kind: `b2b-storefront-permissions-${name}`, + name: B2B_METRIC_NAME, + } + + // Fire-and-forget: measurement must never affect the request. A failure is + // logged (and that log is sampled), which is acceptable - losing one count + // beats failing a session transform. + sendMetric(event).catch((error) => { + logger.warn({ + error: describeClientError(error), + event: name, + message: 'observabilityEvent.sendError', + }) + }) +} diff --git a/node/utils/organizationStatus.ts b/node/utils/organizationStatus.ts new file mode 100644 index 00000000..0b75685a --- /dev/null +++ b/node/utils/organizationStatus.ts @@ -0,0 +1,38 @@ +/** + * Organization lifecycle status. + * + * `b2b-organizations-graphql` owns this vocabulary (its `ORGANIZATION_STATUSES` + * constant and its GraphQL schema declare the canonical values) and its own + * `checkOrganizationIsActive` resolver answers `status === 'active'` - only an + * active organization may be used. + * + * This app reads the organization document straight from Master Data rather than + * through that app's GraphQL, because the extra app hop was measured at roughly + * 1.4s on top of the ~0.4s document read, with samples past 2.2s - the session + * transform's whole budget. The trade-off is that the status rule lives in two + * places, so keep it in this single module and mirror the owner's semantics + * exactly instead of hand-rolling the comparison at each call site - a looser + * check such as `!== 'inactive'` would let `on-hold` organizations through. + */ +export const ORGANIZATION_STATUSES = { + ACTIVE: 'active', + INACTIVE: 'inactive', + ON_HOLD: 'on-hold', +} as const + +const KNOWN_STATUSES: string[] = Object.values(ORGANIZATION_STATUSES) + +/** + * Only an active organization may be used, matching `checkOrganizationIsActive`. + */ +export const isOrganizationUsable = (status?: string | null): boolean => + status === ORGANIZATION_STATUSES.ACTIVE + +/** + * Divergence cannot be prevented structurally without paying for the app hop, so + * make it loud: a status introduced upstream shows up in the logs instead of + * silently falling into the "not usable" branch. Unknown statuses are still + * treated as unusable by `isOrganizationUsable`, which fails closed. + */ +export const isKnownOrganizationStatus = (status?: string | null): boolean => + typeof status === 'string' && KNOWN_STATUSES.includes(status) diff --git a/node/utils/staleFromVBaseWhileRevalidate.ts b/node/utils/staleFromVBaseWhileRevalidate.ts index 81cef0e0..138951ba 100644 --- a/node/utils/staleFromVBaseWhileRevalidate.ts +++ b/node/utils/staleFromVBaseWhileRevalidate.ts @@ -5,6 +5,7 @@ import type { VBase } from '@vtex/api' import type { Logger } from '@vtex/api/lib/service/logger/logger' import type { StaleRevalidateData } from '../typings/staleFromVBaseWhileRevalidate' +import { describeClientError } from './clientError' const DEFAULT_EXPIRATION_IN_MINUTES = 30 @@ -57,7 +58,7 @@ const revalidate = async ( .catch((error) => { logger?.error({ bucket, - error, + error: describeClientError(error), key, message: 'staleFromVBase.saveError', }) @@ -96,7 +97,7 @@ export const staleFromVBaseWhileRevalidate = async ( // still be visible somewhere. logger?.warn({ bucket, - error, + error: describeClientError(error), key: filePath, message: 'staleFromVBase.readError', }) @@ -137,7 +138,7 @@ export const staleFromVBaseWhileRevalidate = async ( // would go completely unnoticed. logger?.error({ bucket, - error, + error: describeClientError(error), key: filePath, message: 'staleFromVBase.revalidateError', }) diff --git a/vtex.session/configuration.json b/vtex.session/configuration.json index 6c902996..4bc3b5da 100644 --- a/vtex.session/configuration.json +++ b/vtex.session/configuration.json @@ -5,7 +5,7 @@ "checkout": ["orderFormId"], "impersonate": ["storeUserEmail", "storeUserId"], "public": ["impersonate", "removeB2B", "b2bCurrentCostCenter", "costCenterAddressId", "allowRegionOverwrite", "postalCode", "country"], - "storefront-permissions": ["hash"] + "storefront-permissions": ["hash", "organization", "costcenter"] }, "output": { "public": ["facets", "sc", "regionId", "postalCode", "country"], From c32d5604d79b528717e622a6416c22257848cd2e Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Sat, 22 Aug 2026 12:52:21 -0300 Subject: [PATCH 08/19] fix: address Copilot review round three - Route the active-user lookup failure log through describeClientError (the one catch the sweep missed, key-at-end form) - Redact emails from the described stack: its first line repeats the error message, which survived truncation unredacted - staleFromVBase logs reference the hashed storage key, never the logical key - the active-user key contains the shopper's email - Validate the fallback organization's status before recovery adopts it: a stale list nomination could recover into another unusable organization - Recovery also adopts the record id and restamps userId, so the price-table lookup and the emitted userId agree with the adopted pair - Start the marketing-tags lookup after recovery, from the final cost center id, so a recovered session tags the cart for the right pair - getUserOrganizationsData only nominates a record whose usable organization and live cost center come from the *same* record, both in the early-stop check and in the selection - Region handoff cart guard documented as a deliberate parity limitation --- CHANGELOG.md | 2 +- docs/REGION_RESOLUTION.md | 2 + node/__tests__/clientError.test.ts | 9 +++ .../getUserOrganizationsData.test.ts | 73 +++++++++++++++++++ node/__tests__/setProfile.test.ts | 52 +++++++++++++ .../staleFromVBaseWhileRevalidate.test.ts | 8 +- node/resolvers/Routes/index.ts | 43 ++++++++--- node/resolvers/Routes/utils/index.ts | 17 +++-- node/utils/clientError.ts | 4 +- node/utils/staleFromVBaseWhileRevalidate.ts | 14 ++-- 10 files changed, 197 insertions(+), 27 deletions(-) create mode 100644 node/__tests__/getUserOrganizationsData.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92471bd9..c71d2f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Session transform telemetry: a `withRequestTimings` middleware logs one `setProfile.timings` line with per-step durations when a request is slow (default threshold 1000ms, configurable via `sessionTimingsSlowThresholdMs`) or sampled (`sessionTimingsSampleRate`, default 0), and always when the transform throws (`failed: true`), so incidents show which dependency degraded without redeploying. - Per-pod cache hit-rate and size stats logged as `cacheStats` every five minutes, piggybacked on the session transform route. - New app setting `logSessionPayloads` (default `false`) gating the full request/response session payload log, which previously ran on every transform and included the shopper's email and organization data. -- Errors in the stale-while-revalidate cache layer are now logged (`staleFromVBase.readError`, `saveError`, `revalidateError`) instead of being silently swallowed - in particular a failing origin behind a stale-served cache is now visible. +- Errors in the stale-while-revalidate cache layer are now logged (`staleFromVBase.readError`, `saveError`, `revalidateError`) instead of being silently swallowed - in particular a failing origin behind a stale-served cache is now visible. These logs reference the hashed storage key, never the logical key, which can carry identifiers (the active-user key contains the shopper's email). - Jest test suite (77 tests) covering the caches, the stale-while-revalidate helper, the timings middleware, the `checkPermissions` cache, and `setProfile` behaviors: sales channel deferral, region handoff and its fallbacks, organization-switch cache invalidation, payload log gating, session watcher kill switch, and the inactive-organization recovery path. ### Changed diff --git a/docs/REGION_RESOLUTION.md b/docs/REGION_RESOLUTION.md index e35ec62d..80939a65 100644 --- a/docs/REGION_RESOLUTION.md +++ b/docs/REGION_RESOLUTION.md @@ -22,6 +22,8 @@ When enabled, this app stops calling the regions API. Instead it publishes the c Safety valves: the handoff requires the cost center address to have a country **and** postal code (checkout-session's input contract); otherwise this app falls back to resolving the region itself. And it stands down entirely when region overwrite is active for the request. +Known limitation, on purpose: locality is published only when the session has an order form, which is the same guard the default (non-handoff) region resolution has always had — without a cart, neither mode resolves a region. Publishing locality for cartless sessions would change *when* checkout-session and search-session perform region work, so it belongs to its own measured change, not to this flag. + > Rollout note: because search starts seeing the cost center locality, QA product availability and delivery promises for a B2B user before enabling this on an account. ## `allowRegionOverwrite` — the shopper's "check delivery to another location" diff --git a/node/__tests__/clientError.test.ts b/node/__tests__/clientError.test.ts index 25dbc484..2f417e6b 100644 --- a/node/__tests__/clientError.test.ts +++ b/node/__tests__/clientError.test.ts @@ -86,6 +86,15 @@ describe('describeClientError', () => { expect(describeClientError(undefined)).toBeNull() }) + it('redacts emails from the stack, whose first line repeats the message', () => { + const error = new Error('user shopper@secret.com not found') + + const described: any = describeClientError(error) + + expect(described.stack).toContain('') + expect(described.stack).not.toContain('shopper@secret.com') + }) + it('keeps the stack to a few lines of code locations', () => { const longStack = ['Error: x', ...Array(30).fill(' at somewhere')].join( '\n' diff --git a/node/__tests__/getUserOrganizationsData.test.ts b/node/__tests__/getUserOrganizationsData.test.ts new file mode 100644 index 00000000..d362cfd4 --- /dev/null +++ b/node/__tests__/getUserOrganizationsData.test.ts @@ -0,0 +1,73 @@ +import { getUserOrganizationsData } from '../resolvers/Routes/utils' + +const makeCtx = (records: any[]): any => ({ + clients: { + organizations: { + getOrganizationsPaginatedByEmail: jest.fn().mockResolvedValue({ + data: { + getOrganizationsPaginatedByEmail: { + data: records, + pagination: { page: 1, pageSize: 200, total: records.length }, + }, + }, + }), + }, + }, + vtex: { logger: { error: jest.fn(), warn: jest.fn() } }, +}) + +const record = (overrides: Record) => ({ + costCenterName: 'CC', + costId: 'cc1', + id: 'r1', + orgId: 'org1', + organizationStatus: 'active', + ...overrides, +}) + +// The module keeps a per-email in-memory cache, so each test uses its own +// email to stay isolated. +let uniq = 0 +const nextEmail = () => `buyer${uniq++}@test.com` + +describe('getUserOrganizationsData', () => { + it('only nominates a record whose organization AND cost center are usable together', async () => { + // An active organization whose cost center was deleted must not be + // adopted: its costId is what gets stamped on the session, so the pair + // would be broken. The usable pair further down the list wins. + const ctx = makeCtx([ + record({ costCenterName: null, costId: 'ccGone', id: 'rA', orgId: 'orgA' }), + record({ costId: 'ccB', id: 'rB', orgId: 'orgB' }), + ]) + + const result = await getUserOrganizationsData(nextEmail(), ctx, false) + + expect(result.activeOrganization).toMatchObject({ + costId: 'ccB', + orgId: 'orgB', + }) + }) + + it('nominates nothing when no record pairs a usable organization with a live cost center', async () => { + // One record has the organization, the other has the cost center - but no + // single record has both, and the costId comes from the nominated record. + const ctx = makeCtx([ + record({ costCenterName: null, id: 'rA', orgId: 'orgA' }), + record({ id: 'rB', organizationStatus: 'inactive', orgId: 'orgB' }), + ]) + + const result = await getUserOrganizationsData(nextEmail(), ctx, false) + + expect(result.activeOrganization).toBeNull() + // The valid cost center is still reported for the invalid-cost-center path. + expect(result.validCostCenterId).toBe('cc1') + }) + + it('does not treat an on-hold organization as usable', async () => { + const ctx = makeCtx([record({ organizationStatus: 'on-hold' })]) + + const result = await getUserOrganizationsData(nextEmail(), ctx, false) + + expect(result.activeOrganization).toBeNull() + }) +}) diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts index 6c42267d..1bf52e6e 100644 --- a/node/__tests__/setProfile.test.ts +++ b/node/__tests__/setProfile.test.ts @@ -348,6 +348,17 @@ describe('setProfile', () => { 'cost2' ) + // The record id follows the adopted pair: the emitted userId must agree + // with the organization this response is stamped with, and the + // price-table lookup reads from it. + expect(response['storefront-permissions'].userId.value).toBe('u2') + + // Marketing tags are fetched for the cost center the session was actually + // placed in, not the unusable one it arrived with. + expect(ctx.clients.organizations.getMarketingTags).toHaveBeenCalledWith( + 'cost2' + ) + // Recovery must never write: which record is active belongs to the // shopper (organization switch) or to the account admin, so the transform // only shapes this response and reports what it found. @@ -444,6 +455,47 @@ describe('setProfile', () => { expect(setActiveUserByOrganization).not.toHaveBeenCalled() }) + it('does not recover into a fallback organization that is itself unusable', async () => { + const orgsDataMock = getUserOrganizationsData as jest.Mock + + orgsDataMock.mockResolvedValue({ + activeOrganization: { costId: 'cost2', id: 'u2', orgId: 'org2' }, + validCostCenterId: null, + }) + + // The list entry nominated org2, but its freshly fetched document says + // otherwise - the nomination is stale. Adopting it would just move the + // shopper into another unusable organization. + const ctx = makeCtx({ + organization: { + collections: null, + name: 'Inactive Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'inactive', + tradeName: null, + }, + recoveredOrganization: { + collections: null, + name: 'Also On Hold', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'on-hold', + tradeName: null, + }, + }) + + await expect(run(ctx)).rejects.toThrow() + + const reported = ctx.vtex.logger.error.mock.calls.find( + (call: any[]) => call[0]?.message === 'setProfile.organizationUnavailable' + ) + + expect(reported?.[0]).toMatchObject({ reason: 'organizationNotActive' }) + }) + it('clears the cart on inactive-org recovery even when the session hash matched the old org', async () => { const orgsDataMock = getUserOrganizationsData as jest.Mock diff --git a/node/__tests__/staleFromVBaseWhileRevalidate.test.ts b/node/__tests__/staleFromVBaseWhileRevalidate.test.ts index 7186f305..90b114fb 100644 --- a/node/__tests__/staleFromVBaseWhileRevalidate.test.ts +++ b/node/__tests__/staleFromVBaseWhileRevalidate.test.ts @@ -140,7 +140,13 @@ describe('staleFromVBaseWhileRevalidate', () => { const payload = logger.error.mock.calls[0][0] expect(payload.message).toBe('staleFromVBase.revalidateError') - expect(payload.key).toBe('my-logical-key') + + // The log must reference the hashed storage name, never the logical key: + // logical keys can carry identifiers (the active-user key contains the + // shopper's email). The hash is deterministic, so a known key can still be + // located by hashing it. + expect(JSON.stringify(payload)).not.toContain('my-logical-key') + expect(payload.key).toMatch(/^[0-9a-f]{32}\.json$/) }) it('logs a warning when the VBase read fails and the origin is used', async () => { diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index 03bfbb19..6157bb0e 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -390,7 +390,10 @@ export const Routes = { ) .catch((error) => { if (!error?.userNotFound) { - logger.warn({ message: 'setProfile.getUserByEmailError', error }) + logger.warn({ + error: describeClientError(error), + message: 'setProfile.getUserByEmailError', + }) } }) @@ -489,16 +492,6 @@ export const Routes = { orgId: user.orgId, } - // Marketing tags only feed a fire-and-forget cart update further down, so - // keep them off the critical path (do not await here). - const marketingTagsPromise = organizations - .getMarketingTags(user.costId) - .catch((error) => { - logger.error({ error: describeClientError(error), message: 'setProfile.getMarketingTags' }) - - return null - }) - // Read into locals so the cache fetcher below does not close over `user`, // which is reassigned further down. const resolvedCostId = user.costId @@ -656,7 +649,13 @@ export const Routes = { } } - if (fallbackOrganization) { + // The list entry that nominated this organization can be stale: validate + // the freshly fetched document before adopting it, or the recovery would + // move the shopper into another unusable organization. + if ( + fallbackOrganization && + isOrganizationUsable(fallbackOrganization.status) + ) { // Captured before user.orgId is reassigned below, so the log can say // which organization the shopper's stored selection points at. const unusableOrgId = user.orgId @@ -677,6 +676,11 @@ export const Routes = { user.orgId = validOrganization.orgId user.costId = fallbackCostId + // The record id must follow too: `getB2BUserById` below reads the + // selected price table from `user.id`, and the emitted `userId` must + // agree with the organization this response is stamped with. + user.id = validOrganization.id + response['storefront-permissions'].userId.value = user.id response['storefront-permissions'].organization.value = user.orgId // Recompute against the adopted organization: the value derived above @@ -759,6 +763,21 @@ export const Routes = { } } + // Marketing tags only feed a fire-and-forget cart update further down, so + // keep them off the critical path (do not await here). Started only after + // the recovery above so a recovered session fetches the tags of the cost + // center it was actually placed in, not of the unusable one it arrived with. + const marketingTagsPromise = organizations + .getMarketingTags(user.costId) + .catch((error) => { + logger.error({ + error: describeClientError(error), + message: 'setProfile.getMarketingTags', + }) + + return null + }) + businessName = organization.name tradeName = organization.tradeName diff --git a/node/resolvers/Routes/utils/index.ts b/node/resolvers/Routes/utils/index.ts index f1349984..be6c5fd5 100644 --- a/node/resolvers/Routes/utils/index.ts +++ b/node/resolvers/Routes/utils/index.ts @@ -9,6 +9,15 @@ import { getUserById } from '../../Queries/Users' // Simple in-memory cache with TTL const organizationsCache = new Map() +/** + * A record is only worth adopting when the *same* record pairs a usable + * organization with a live cost center: its `costId` is what the recovery + * stamps on the session, so an active organization whose cost center was + * deleted (`costCenterName === null`) would recover into a broken pair. + */ +const isAdoptableRecord = (org: GetOrganizationByEmailBase) => + isOrganizationUsable(org.organizationStatus) && org.costCenterName !== null + export class ErrorResponse extends Error { public response: { status: number @@ -234,9 +243,7 @@ export const getUserOrganizationsData = async ( (org) => org.costCenterName !== null ) - const hasActiveOrg = firstPageData.some((org) => - isOrganizationUsable(org.organizationStatus) - ) + const hasActiveOrg = firstPageData.some(isAdoptableRecord) // Only fetch more pages if we're missing data if (!hasValidCostCenter || !hasActiveOrg) { @@ -274,9 +281,7 @@ export const getUserOrganizationsData = async ( (org) => org.costCenterName !== null ) - const activeOrg = allOrganizations.find((org) => - isOrganizationUsable(org.organizationStatus) - ) + const activeOrg = allOrganizations.find(isAdoptableRecord) // Surface a status this app does not know about, so a value introduced by // b2b-organizations shows up here instead of silently being treated as diff --git a/node/utils/clientError.ts b/node/utils/clientError.ts index a9e7fd6c..b577255a 100644 --- a/node/utils/clientError.ts +++ b/node/utils/clientError.ts @@ -56,9 +56,11 @@ export const describeClientError = (error: any) => { headers['x-vtex-operation-id'] ?? body?.operationId ?? null, path: stripQuery(error?.config?.url), requestId: headers['x-request-id'] ?? null, + // Redacted like `message`: a stack's first line repeats the error message, + // so anything echoed into it would otherwise survive the truncation. stack: typeof error?.stack === 'string' - ? error.stack.split('\n').slice(0, 5).join('\n') + ? redact(error.stack.split('\n').slice(0, 5).join('\n')) : null, status: error?.response?.status ?? null, vtexErrorCode: bodyError?.code ?? headers['x-vtex-error-code'] ?? null, diff --git a/node/utils/staleFromVBaseWhileRevalidate.ts b/node/utils/staleFromVBaseWhileRevalidate.ts index 138951ba..553bd94e 100644 --- a/node/utils/staleFromVBaseWhileRevalidate.ts +++ b/node/utils/staleFromVBaseWhileRevalidate.ts @@ -33,6 +33,11 @@ const getTTL = (expirationInMinutes?: number) => { /** * VBase keys have a restricted charset, so hash the logical key to keep * callers free to use any descriptive string. + * + * The logs below reference this hashed storage name, never the logical key: + * logical keys can carry identifiers (the active-user key contains the + * shopper's email), and error logs must not. The hash is deterministic, so a + * known logical key can still be located by hashing it. */ const normalizedJSONFile = (filePath: string) => `${createHash('md5').update(filePath).digest('hex')}.json` @@ -41,7 +46,6 @@ const revalidate = async ( vbase: VBase, bucket: string, filePath: string, - key: string, endDate: Date, validateFunction: (params?: any) => Promise, params?: unknown, @@ -59,7 +63,7 @@ const revalidate = async ( logger?.error({ bucket, error: describeClientError(error), - key, + key: filePath, message: 'staleFromVBase.saveError', }) }) @@ -98,7 +102,7 @@ export const staleFromVBaseWhileRevalidate = async ( logger?.warn({ bucket, error: describeClientError(error), - key: filePath, + key: normalizedFilePath, message: 'staleFromVBase.readError', }) @@ -110,7 +114,6 @@ export const staleFromVBaseWhileRevalidate = async ( vbase, bucket, normalizedFilePath, - filePath, getTTL(options?.expirationInMinutes), validateFunction, params, @@ -128,7 +131,6 @@ export const staleFromVBaseWhileRevalidate = async ( vbase, bucket, normalizedFilePath, - filePath, getTTL(options?.expirationInMinutes), validateFunction, params, @@ -139,7 +141,7 @@ export const staleFromVBaseWhileRevalidate = async ( logger?.error({ bucket, error: describeClientError(error), - key: filePath, + key: normalizedFilePath, message: 'staleFromVBase.revalidateError', }) }) From 52a5635ac22dac5ef07590f8efc857c0bafa138a Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Sat, 22 Aug 2026 13:05:13 -0300 Subject: [PATCH 09/19] fix: satisfy the platform builder's noImplicitAny on the user variable The builder compiles with noImplicitAny under an older TypeScript that cannot infer the type of `let user = null` through its reassignments, and the fire-and-forget catch closures that capture `user` turn that gap into TS7034/TS7005 build errors. Local tsc accepts it, which is why CI's vtexio/build was the first to fail. Reproduced and verified against the real builder via a linked workspace. --- node/resolvers/Routes/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index 6157bb0e..44974ca8 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -229,7 +229,11 @@ export const Routes = { let phoneNumber = null let tradeName = null let stateRegistration = null - let user = null + // Explicitly typed: the platform builder compiles with noImplicitAny under + // an older TypeScript that cannot infer this union through the + // reassignments below, and closures capturing `user` (the fire-and-forget + // catch handlers) turn that inference gap into a build error (TS7034/7005). + let user: any = null const ignoreB2B = body?.public?.removeB2B?.value From 725d5023325217313172fea825e3065b22c6ff6a Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Sat, 22 Aug 2026 13:16:28 -0300 Subject: [PATCH 10/19] Deploy beta 1 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 3a5018b2..e4c56241 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "storefront-permissions", "vendor": "vtex", - "version": "3.6.3", + "version": "3.7.0-beta.0", "title": "Storefront Permissions", "description": "Manage User's permissions on apps that relates to this app", "mustUpdateAt": "2022-08-28", From b388e25ea539b74294a53e7342a6a7fdc703d3fb Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Sat, 22 Aug 2026 13:20:00 -0300 Subject: [PATCH 11/19] fix: address Copilot review round four - Tenant-scope getUserOrganizationsData's module-level cache: keyed only by email, a pod serving two accounts could hand one account's organization ids to the same email on the other - Route the three remaining raw client-error sites through describeClientError (two logger calls with the error key at the end, plus changeTeam's console.warn, whose payload carries the user email) - Validate the session-pinned cost center before recovery adopts the pinned pair: a deleted cost center would replace the validated list candidate with a broken pair - setProfile.regionIdSkipped reports postal-code presence, never the value --- .../getUserOrganizationsData.test.ts | 27 ++- node/__tests__/setProfile.test.ts | 211 ++++++++++++++++++ node/resolvers/Routes/index.ts | 43 +++- node/resolvers/Routes/utils/index.ts | 11 +- node/utils/metrics/changeTeam.ts | 5 +- 5 files changed, 285 insertions(+), 12 deletions(-) diff --git a/node/__tests__/getUserOrganizationsData.test.ts b/node/__tests__/getUserOrganizationsData.test.ts index d362cfd4..5b4d142e 100644 --- a/node/__tests__/getUserOrganizationsData.test.ts +++ b/node/__tests__/getUserOrganizationsData.test.ts @@ -1,6 +1,6 @@ import { getUserOrganizationsData } from '../resolvers/Routes/utils' -const makeCtx = (records: any[]): any => ({ +const makeCtx = (records: any[], account = 'acc'): any => ({ clients: { organizations: { getOrganizationsPaginatedByEmail: jest.fn().mockResolvedValue({ @@ -13,7 +13,11 @@ const makeCtx = (records: any[]): any => ({ }), }, }, - vtex: { logger: { error: jest.fn(), warn: jest.fn() } }, + vtex: { + account, + logger: { error: jest.fn(), warn: jest.fn() }, + workspace: 'master', + }, }) const record = (overrides: Record) => ({ @@ -70,4 +74,23 @@ describe('getUserOrganizationsData', () => { expect(result.activeOrganization).toBeNull() }) + + it('never serves one tenant the cached organizations of another', async () => { + // A pod serves multiple accounts and this cache is a module-level Map, so + // the key must be tenant-scoped: the same email can exist in two accounts + // with entirely different organizations. + const email = nextEmail() + const ctxA = makeCtx([record({ costId: 'ccA', orgId: 'orgA' })], 'accA') + const ctxB = makeCtx([record({ costId: 'ccB', orgId: 'orgB' })], 'accB') + + const first = await getUserOrganizationsData(email, ctxA, true) + const second = await getUserOrganizationsData(email, ctxB, true) + + expect(first.activeOrganization).toMatchObject({ orgId: 'orgA' }) + expect(second.activeOrganization).toMatchObject({ orgId: 'orgB' }) + // And the second call must have hit its own origin, not account A's cache. + expect( + ctxB.clients.organizations.getOrganizationsPaginatedByEmail + ).toHaveBeenCalled() + }) }) diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts index 1bf52e6e..a4e29fe0 100644 --- a/node/__tests__/setProfile.test.ts +++ b/node/__tests__/setProfile.test.ts @@ -455,6 +455,217 @@ describe('setProfile', () => { expect(setActiveUserByOrganization).not.toHaveBeenCalled() }) + it('prefers the session-pinned pair on recovery when it is fully valid', async () => { + const orgsDataMock = getUserOrganizationsData as jest.Mock + + orgsDataMock.mockResolvedValue({ + activeOrganization: { costId: 'cost2', id: 'u2', orgId: 'org2' }, + validCostCenterId: null, + }) + + const ctx = makeCtx({ + organization: { + collections: null, + name: 'Inactive Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'inactive', + tradeName: null, + }, + recoveredOrganization: { + collections: null, + name: 'Recovered Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + userDocs: [ + { + active: true, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + }, + { + active: false, + clId: 'clS', + costId: 'costSticky', + email: 'buyer@test.com', + id: 'uS', + name: 'Buyer', + orgId: 'orgSticky', + }, + ], + }) + + // The sticky organization must resolve as usable too. + ctx.clients.masterDataExtended.getDocumentById.mockImplementation( + (entity: string, id: string) => { + if (entity !== 'organizations') return Promise.resolve(undefined) + if (id === 'org1') + return Promise.resolve({ name: 'Inactive Org', status: 'inactive' }) + if (id === 'orgSticky') + return Promise.resolve({ + collections: null, + name: 'Sticky Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }) + + return Promise.resolve(undefined) + } + ) + + const response = await run(ctx, { + ...makeBody(), + 'storefront-permissions': { + costcenter: { value: 'costSticky' }, + hash: { value: '' }, + organization: { value: 'orgSticky' }, + }, + }) + + // The pinned pair wins over the list candidate, keeping consecutive + // responses stable. + expect(response['storefront-permissions'].organization.value).toBe( + 'orgSticky' + ) + expect(response['storefront-permissions'].costcenter.value).toBe( + 'costSticky' + ) + }) + + it('does not adopt the pinned pair when its cost center no longer exists', async () => { + const orgsDataMock = getUserOrganizationsData as jest.Mock + + orgsDataMock.mockResolvedValue({ + activeOrganization: { costId: 'cost2', id: 'u2', orgId: 'org2' }, + validCostCenterId: null, + }) + + const ctx = makeCtx({ + organization: { + collections: null, + name: 'Inactive Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'inactive', + tradeName: null, + }, + recoveredOrganization: { + collections: null, + name: 'Recovered Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }, + userDocs: [ + { + active: true, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + }, + { + active: false, + clId: 'clS', + costId: 'costGone', + email: 'buyer@test.com', + id: 'uS', + name: 'Buyer', + orgId: 'orgSticky', + }, + ], + }) + + ctx.clients.masterDataExtended.getDocumentById.mockImplementation( + (entity: string, id: string) => { + if (entity !== 'organizations') return Promise.resolve(undefined) + if (id === 'org1') + return Promise.resolve({ name: 'Inactive Org', status: 'inactive' }) + if (id === 'orgSticky') + return Promise.resolve({ name: 'Sticky Org', status: 'active' }) + if (id === 'org2') + return Promise.resolve({ + collections: null, + name: 'Recovered Org', + priceTables: null, + salesChannel: null, + sellers: null, + status: 'active', + tradeName: null, + }) + + return Promise.resolve(undefined) + } + ) + + // The pinned record's cost center was deleted: Master Data answers a + // document whose fields are all null. + ctx.clients.organizations.getCostCenterById.mockImplementation( + (id: string) => + id === 'costGone' + ? Promise.resolve({ + data: { + getCostCenterById: { + addresses: null, + businessDocument: null, + phoneNumber: null, + sellers: null, + stateRegistration: null, + }, + }, + }) + : Promise.resolve({ + data: { + getCostCenterById: { + addresses: [defaultAddress], + businessDocument: null, + phoneNumber: null, + sellers: null, + stateRegistration: null, + }, + }, + }) + ) + + const response = await run(ctx, { + ...makeBody(), + 'storefront-permissions': { + costcenter: { value: 'costGone' }, + hash: { value: '' }, + organization: { value: 'orgSticky' }, + }, + }) + + // Falls back to the list candidate, whose pair was validated, instead of + // emitting a session for a deleted cost center. + expect(response['storefront-permissions'].organization.value).toBe('org2') + expect(response['storefront-permissions'].costcenter.value).toBe('cost2') + + const reported = ctx.vtex.logger.warn.mock.calls.find( + (call: any[]) => + call[0]?.message === 'setProfile.stickyCostCenterInvalidOnRecovery' + ) + + expect(reported?.[0]).toMatchObject({ stickyCostId: 'costGone' }) + }) + it('does not recover into a fallback organization that is itself unusable', async () => { const orgsDataMock = getUserOrganizationsData as jest.Mock diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index 44974ca8..761515e5 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -297,7 +297,10 @@ export const Routes = { response['storefront-permissions'].storeUserId.value = userId response['storefront-permissions'].storeUserEmail.value = user.email } catch (error) { - logger.error({ message: 'setProfile.getUserError', error }) + logger.error({ + error: describeClientError(error), + message: 'setProfile.getUserError', + }) } } else if (telemarketingImpersonate) { const telemarketingEmail = body?.impersonate?.storeUserEmail?.value @@ -643,12 +646,36 @@ export const Routes = { ) if (stickyRecord) { - validOrganization = { - costId: stickyRecord.costId, - id: stickyRecord.id, - orgId: stickyRecord.orgId, + // The record existing is not enough: its cost center may have been + // deleted since the session was pinned, and adopting it would + // replace the list candidate (whose cost center was validated by + // isAdoptableRecord) with a broken pair, emitting a session for a + // cost center that no longer exists. + const stickyCostCenter = await timer.track( + 'getCostCenterById.stickyValidation', + getCachedCostCenter(ctx, String(stickyRecord.costId), () => + organizations.getCostCenterById(stickyRecord.costId) + ) + ) + + const stickyCostCenterValid = !Object.values( + stickyCostCenter?.data?.getCostCenterById ?? {} + ).every((value) => value === null) + + if (stickyCostCenterValid) { + validOrganization = { + costId: stickyRecord.costId, + id: stickyRecord.id, + orgId: stickyRecord.orgId, + } + fallbackOrganization = stickyOrganization + } else { + logger.warn({ + message: 'setProfile.stickyCostCenterInvalidOnRecovery', + stickyCostId: stickyRecord.costId, + stickyOrgId, + }) } - fallbackOrganization = stickyOrganization } } } @@ -1092,11 +1119,13 @@ export const Routes = { } else { response.public.regionId = { value: '' } logger.info({ + // Presence only: the value itself is a shopper-provided address + // datum and must not reach the logs. + hasPublicPostalCode: !!body?.public?.postalCode?.value, message: 'setProfile.regionIdSkipped', reason: usePublicPostalCodeForRegion ? 'usePublicPostalCodeForRegion' : 'noSalesChannelAvailable', - publicPostalCode: body?.public?.postalCode?.value ?? null, }) } diff --git a/node/resolvers/Routes/utils/index.ts b/node/resolvers/Routes/utils/index.ts index be6c5fd5..bc06f650 100644 --- a/node/resolvers/Routes/utils/index.ts +++ b/node/resolvers/Routes/utils/index.ts @@ -149,7 +149,10 @@ export const generateClUser = async ({ } const clUser = await getUserById(null, { id: clId }, ctx).catch((error) => { - logger.error({ message: 'setProfile.getUserByIdError', error }) + logger.error({ + error: describeClientError(error), + message: 'setProfile.getUserByIdError', + }) }) if (!clUser) { @@ -200,7 +203,11 @@ export const getUserOrganizationsData = async ( activeOrganization: GetOrganizationByEmailBase | null }> => { const CACHE_TTL = 5 * 60 * 1000 // 5 minutes cache - const cacheKey = `orgs-${email}` + + // Tenant-scoped: this module-level Map is shared by every account the pod + // serves, so a key of just the email would hand one account's organization + // ids to the same email on another account. + const cacheKey = `${ctx.vtex.account}-${ctx.vtex.workspace}-orgs-${email}` // Check cache first if (useCache) { diff --git a/node/utils/metrics/changeTeam.ts b/node/utils/metrics/changeTeam.ts index 553fba99..2196d61d 100644 --- a/node/utils/metrics/changeTeam.ts +++ b/node/utils/metrics/changeTeam.ts @@ -1,5 +1,6 @@ import type { Metric } from '../../clients/metrics' import { B2B_METRIC_NAME, sendMetric } from '../../clients/metrics' +import { describeClientError } from '../clientError' type ChangeTeamFieldsMetric = { date: string @@ -51,6 +52,8 @@ export const sendChangeTeamMetric = async (metricParams: ChangeTeamParams) => { await sendMetric(metric) } catch (error) { - console.warn('Unable to log metrics', error) + // The raw client error carries the request body - here, the metric + // payload, which includes the user's email. + console.warn('Unable to log metrics', describeClientError(error)) } } From 94d83374367d7355d20a7801cc435fff395169f0 Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Sat, 22 Aug 2026 13:20:57 -0300 Subject: [PATCH 12/19] style: brace if statements in test mocks (tslint) --- node/__tests__/setProfile.test.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts index a4e29fe0..3f39e294 100644 --- a/node/__tests__/setProfile.test.ts +++ b/node/__tests__/setProfile.test.ts @@ -507,10 +507,15 @@ describe('setProfile', () => { // The sticky organization must resolve as usable too. ctx.clients.masterDataExtended.getDocumentById.mockImplementation( (entity: string, id: string) => { - if (entity !== 'organizations') return Promise.resolve(undefined) - if (id === 'org1') + if (entity !== 'organizations') { + return Promise.resolve(undefined) + } + + if (id === 'org1') { return Promise.resolve({ name: 'Inactive Org', status: 'inactive' }) - if (id === 'orgSticky') + } + + if (id === 'orgSticky') { return Promise.resolve({ collections: null, name: 'Sticky Org', @@ -520,6 +525,7 @@ describe('setProfile', () => { status: 'active', tradeName: null, }) + } return Promise.resolve(undefined) } @@ -595,12 +601,19 @@ describe('setProfile', () => { ctx.clients.masterDataExtended.getDocumentById.mockImplementation( (entity: string, id: string) => { - if (entity !== 'organizations') return Promise.resolve(undefined) - if (id === 'org1') + if (entity !== 'organizations') { + return Promise.resolve(undefined) + } + + if (id === 'org1') { return Promise.resolve({ name: 'Inactive Org', status: 'inactive' }) - if (id === 'orgSticky') + } + + if (id === 'orgSticky') { return Promise.resolve({ name: 'Sticky Org', status: 'active' }) - if (id === 'org2') + } + + if (id === 'org2') { return Promise.resolve({ collections: null, name: 'Recovered Org', @@ -610,6 +623,7 @@ describe('setProfile', () => { status: 'active', tradeName: null, }) + } return Promise.resolve(undefined) } From f615f9cf433a7f2809f6230761e289c4ae3629bf Mon Sep 17 00:00:00 2001 From: Mateus Saggin Date: Sat, 22 Aug 2026 13:22:25 -0300 Subject: [PATCH 13/19] Release v3.7.0-beta.1 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index e4c56241..89309291 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "storefront-permissions", "vendor": "vtex", - "version": "3.7.0-beta.0", + "version": "3.7.0-beta.1", "title": "Storefront Permissions", "description": "Manage User's permissions on apps that relates to this app", "mustUpdateAt": "2022-08-28", From b50ec6ff2e1a63245b1ee1cd9546117e966661df Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Wed, 26 Aug 2026 10:06:05 -0400 Subject: [PATCH 14/19] gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 09c9c24a..80361893 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules .idea .qodo .cursor -.claude \ No newline at end of file +.claude +AGENTS.md \ No newline at end of file From 75b62a6de9dbdff5cccb6a46eebe3c60ad59341e Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Wed, 26 Aug 2026 10:06:48 -0400 Subject: [PATCH 15/19] Release v3.7.0 --- CHANGELOG.md | 2 ++ manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c71d2f81..210eb1fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [3.7.0] - 2026-08-26 + ### Added - Two-layer caching (per-pod in-memory LRU + cross-pod VBase stale-while-revalidate) for the data `setProfile` reads on every session transform: app settings, sales channel list, B2B settings, organization, cost center, active user, region lookup, session watcher flag (memory-only, it already lives in VBase) and roles (memory-only, same reason). Warm-pod transform time drops from roughly 1.2s to under 150ms, and a cold pod reads the entry a sibling pod populated instead of paying the origin call. The cost center cache is bounded by bytes rather than entry count, because its documents were measured spanning 400B to 29KB. diff --git a/manifest.json b/manifest.json index 89309291..c5f28576 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "storefront-permissions", "vendor": "vtex", - "version": "3.7.0-beta.1", + "version": "3.7.0", "title": "Storefront Permissions", "description": "Manage User's permissions on apps that relates to this app", "mustUpdateAt": "2022-08-28", From 1511ec9f3f1d9c459b1cef81d67121ae77f2fb87 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Wed, 26 Aug 2026 10:09:52 -0400 Subject: [PATCH 16/19] Release v3.8.0 --- CHANGELOG.md | 2 ++ manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 210eb1fa..5986d330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [3.8.0] - 2026-08-26 + ## [3.7.0] - 2026-08-26 ### Added diff --git a/manifest.json b/manifest.json index c5f28576..235570c9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "storefront-permissions", "vendor": "vtex", - "version": "3.7.0", + "version": "3.8.0", "title": "Storefront Permissions", "description": "Manage User's permissions on apps that relates to this app", "mustUpdateAt": "2022-08-28", From d43deea80e7dab8fe1dd28bb1c06b33ce8dcfca2 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Wed, 26 Aug 2026 12:38:09 -0400 Subject: [PATCH 17/19] Portando melhorias do PR 202 --- .eslintrc | 3 + docs/PERFORMANCE_AND_CACHING.md | 50 +-- .../getUserOrganizationsData.test.ts | 81 +++-- node/__tests__/setProfile.test.ts | 94 +++-- node/clients/Organizations.ts | 55 +-- node/clients/masterDataExtended.ts | 51 ++- node/resolvers/Mutations/Users.ts | 20 +- node/resolvers/Routes/index.ts | 140 ++++---- node/resolvers/Routes/utils/index.ts | 324 ++++++++++-------- node/services/organizationsCache.ts | 10 +- node/utils/constants.ts | 28 ++ 11 files changed, 491 insertions(+), 365 deletions(-) diff --git a/.eslintrc b/.eslintrc index 9713dfdd..5c23ed82 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,5 +3,8 @@ "root": true, "env": { "node": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "off" } } \ No newline at end of file diff --git a/docs/PERFORMANCE_AND_CACHING.md b/docs/PERFORMANCE_AND_CACHING.md index 9c300466..8e555c68 100644 --- a/docs/PERFORMANCE_AND_CACHING.md +++ b/docs/PERFORMANCE_AND_CACHING.md @@ -34,28 +34,28 @@ All caches are built by `createCachedResource` (`node/services/cache.ts`), with Caching here has one failure mode worth internalizing, and every rule below is a variant of it: **letting the cache hold a state the origin never produced.** A cache entry is a claim — "this is what the origin returned for this key" — and each rule below protects that claim. Break one and the cache serves wrong responses repeatedly, for the full TTL, to every request that hits it. -1. **Never cache a failure.** A fetcher must rethrow errors, not swallow them into `undefined`/`null`. A swallowed failure gets stored by both layers, turning one transient Master Data blip into minutes of errors served from cache — after the origin has already recovered. Log at the fetcher if useful, but always rethrow; handle the failure *outside* the cached call so the next request retries. (Guarded by the "does not cache a failed organization lookup" test.) +1. **Never cache a failure.** A fetcher must rethrow errors, not swallow them into `undefined`/`null`. A swallowed failure gets stored by both layers, turning one transient Master Data blip into minutes of errors served from cache — after the origin has already recovered. Log at the fetcher if useful, but always rethrow; handle the failure _outside_ the cached call so the next request retries. (Guarded by the "does not cache a failed organization lookup" test.) 2. **Never cache a miss that can be transient.** "User not found" during replication lag — right after someone is added to an organization — is not a fact, it is a race. Caching it pins that shopper to an empty B2B session for the whole TTL. When a miss can be transient, throw a typed marker from the fetcher so nothing is stored, and translate it back at the call site; the cost is one origin lookup per request for that population, which is exactly the pre-cache behavior. (Guarded by the "does not cache a user that was not found" test.) -3. **Never mutate an object returned by a cache.** The memory layer hands out the *same object reference* on every hit, so reassigning a field on it rewrites the shared entry under its original key — every later request receives request-local surgery the origin never returned, and the VBase layer (which stored a serialized snapshot) now *disagrees* with memory, making behavior depend on which layer answers. Treat cached values as read-only; if a request needs to modify one, shallow-clone at the boundary (`{ ...cached }`) — and remember nested arrays/objects are still shared, so deeper mutation needs a deeper copy. (Guarded by the "does not let fallback branches mutate the cached user entry" test.) +3. **Never mutate an object returned by a cache.** The memory layer hands out the _same object reference_ on every hit, so reassigning a field on it rewrites the shared entry under its original key — every later request receives request-local surgery the origin never returned, and the VBase layer (which stored a serialized snapshot) now _disagrees_ with memory, making behavior depend on which layer answers. Treat cached values as read-only; if a request needs to modify one, shallow-clone at the boundary (`{ ...cached }`) — and remember nested arrays/objects are still shared, so deeper mutation needs a deeper copy. (Guarded by the "does not let fallback branches mutate the cached user entry" test.) Corollary for reviews: when a change touches a fetcher or anything downstream of a cached read, ask "can this store or corrupt a state the origin didn't produce?" before asking anything about performance. ### Current resources -| Resource | Origin | Layers | Memory TTL | VBase TTL | Bound | Key | -|---|---|---|---|---|---|---| -| `app-settings` | Apps API | both | 5min | 5min | 50 entries | appId | -| `sales-channel` | catalog `pvt` REST | both | 5min | 6h | 100 | `list` | -| `b2b-settings` | b2b-organizations GraphQL | both | 5min | 5min | 100 | `settings` | -| `organization` | Master Data | both | 60s | 2min | 10000 | orgId | -| `cost-center` | b2b-organizations GraphQL | both | 60s | 2min | **8MB byte budget** | costId | -| `active-user` | Master Data (paginated) | both | 5min¹ | 5min | 10000 | `email\|b2bCurrentCostCenter` | -| `active-user-permissions` | Master Data (paginated) | memory only | 60s | — | 10000 | email | -| `region` | checkout REST | both | 30min | 30min | 10000 | `country\|postalCode\|sc\|geo` | -| `session-watcher` | VBase | memory only | 60s | — | 100 | `active` | -| `roles` | VBase (MD fallback) | memory only | 60s | — | 100 | `all` | +| Resource | Origin | Layers | Memory TTL | VBase TTL | Bound | Key | +| ------------------------- | ------------------------- | ----------- | ---------- | --------- | ------------------- | ------------------------------ | +| `app-settings` | Apps API | both | 5min | 5min | 50 entries | appId | +| `sales-channel` | catalog `pvt` REST | both | 5min | 6h | 100 | `list` | +| `b2b-settings` | b2b-organizations GraphQL | both | 5min | 5min | 100 | `settings` | +| `organization` | Master Data | both | 60s | 2min | 10000 | orgId | +| `cost-center` | Master Data | both | 60s | 2min | **8MB byte budget** | costId | +| `active-user` | Master Data (paginated) | both | 5min¹ | 5min | 10000 | `email\|b2bCurrentCostCenter` | +| `active-user-permissions` | Master Data (paginated) | memory only | 60s | — | 10000 | email | +| `region` | checkout REST | both | 30min | 30min | 10000 | `country\|postalCode\|sc\|geo` | +| `session-watcher` | VBase | memory only | 60s | — | 100 | `active` | +| `roles` | VBase (MD fallback) | memory only | 60s | — | 100 | `all` | ¹ Configurable via the `sessionUserCacheTtlMs` app setting; `0` disables. @@ -71,19 +71,23 @@ Corollary for reviews: when a change touches a fetcher or anything downstream of ### Why the cost center cache is bounded by bytes -Measured on a real account: organization documents span **187–480 bytes** (tight), while cost center documents span **~400 bytes to 29KB** (~70x, driven by the addresses list). A fixed entry count therefore makes the cost-center cache's memory footprint swing by 70x with the data. With a byte budget, `lru-cache` treats `max` as total serialized size: one unusually large document evicts others — and a document larger than the whole budget is *refused*, never stored. Note a parsed object costs roughly 2–3x its serialized length in heap; size budgets accordingly. +Measured on a real account: organization documents span **187–480 bytes** (tight), while cost center documents span **~400 bytes to 29KB** (~70x, driven by the addresses list). A fixed entry count therefore makes the cost-center cache's memory footprint swing by 70x with the data. With a byte budget, `lru-cache` treats `max` as total serialized size: one unusually large document evicts others — and a document larger than the whole budget is _refused_, never stored. Note a parsed object costs roughly 2–3x its serialized length in heap; size budgets accordingly. ## Organization data: Master Data instead of b2b-organizations The organization document is read **straight from Master Data** (`masterDataExtended.getDocumentById('organizations', ...)`) rather than through `b2b-organizations-graphql`, which owns that entity. That substitution came from the `setProfile` performance refactors, and it is deliberate — measured against a real account: -| Read | Samples | Median | -|---|---|---| -| Master Data document | 0.40 / 0.40 / 0.39 / 0.44 / 0.41 / 0.60s | **~0.40s** | -| `b2b-organizations` `getOrganizationById` | 0.99 / 1.19 / 1.80 / 1.87 / 2.24 / 2.35s | **~1.8s** | +| Read | Samples | Median | +| ----------------------------------------- | ---------------------------------------- | ---------- | +| Master Data document | 0.40 / 0.40 / 0.39 / 0.44 / 0.41 / 0.60s | **~0.40s** | +| `b2b-organizations` `getOrganizationById` | 0.99 / 1.19 / 1.80 / 1.87 / 2.24 / 2.35s | **~1.8s** | The extra app hop costs roughly **1.4s**, and individual samples exceeded **2.2s** — the transform's entire budget on their own. The variance is the disqualifying part, not the median. (Measured from a workstation, so both figures include the same client RTT; the delta is server-side. An in-cluster call would be faster in absolute terms, but the spread still rules it out for this path.) +Cost center documents use the same Master Data path (`masterDataExtended.getDocumentById('cost_centers', ...)`). The GraphQL `getCostCenterById` hop was the remaining expensive origin on a cache miss. + +The fallback that lists a shopper's organizations when the current cost center is gone or the organization is unusable also reads Master Data (`b2b_users` search + GET-by-id of `cost_centers` / `organizations`) instead of GraphQL through `b2b-organizations-graphql`, which called back into this app. + The trade-off is that the organization status rule then exists in two implementations. `b2b-organizations` owns the vocabulary (`ORGANIZATION_STATUSES`) and its `checkOrganizationIsActive` defines the semantics — only an `active` organization is usable — and this app mirrors it. Rules that follow from this: @@ -112,10 +116,10 @@ Entry bounds are **global budgets across all tenants on the pod**, not per accou ## Known measurements (Aug 2026, B2B account with multi-organization users) -| Scenario | Before | After | -|---|---|---| -| Warm pod, server-side | ~1240ms | **~50–150ms** | -| Cold pod, warm VBase (scale-up) | ~1870ms | **~950ms** | +| Scenario | Before | After | +| --------------------------------------------- | ------- | --------------------------------------------------------- | +| Warm pod, server-side | ~1240ms | **~50–150ms** | +| Cold pod, warm VBase (scale-up) | ~1870ms | **~950ms** | | Cold pod, cold VBase (first pod after deploy) | ~1870ms | ~1900ms (pays origin once, then warms VBase for all pods) | ## One platform gotcha worth knowing diff --git a/node/__tests__/getUserOrganizationsData.test.ts b/node/__tests__/getUserOrganizationsData.test.ts index 5b4d142e..1143b3f8 100644 --- a/node/__tests__/getUserOrganizationsData.test.ts +++ b/node/__tests__/getUserOrganizationsData.test.ts @@ -1,16 +1,52 @@ import { getUserOrganizationsData } from '../resolvers/Routes/utils' +const record = (overrides: Record) => ({ + costCenterName: 'CC', + costId: 'cc1', + id: 'r1', + orgId: 'org1', + organizationStatus: 'active', + ...overrides, +}) + const makeCtx = (records: any[], account = 'acc'): any => ({ clients: { - organizations: { - getOrganizationsPaginatedByEmail: jest.fn().mockResolvedValue({ - data: { - getOrganizationsPaginatedByEmail: { - data: records, - pagination: { page: 1, pageSize: 200, total: records.length }, - }, - }, - }), + masterDataExtended: { + getDocumentById: jest + .fn() + .mockImplementation((entity: string, id: string) => { + const match = records.find((row) => + entity === 'cost_centers' ? row.costId === id : row.orgId === id + ) + + if (!match) { + return Promise.resolve(null) + } + + if (entity === 'cost_centers') { + return Promise.resolve( + match.costCenterName === null + ? null + : { id, name: match.costCenterName } + ) + } + + if (entity === 'organizations') { + return Promise.resolve({ + id, + status: match.organizationStatus, + }) + } + + return Promise.resolve(null) + }), + searchDocuments: jest.fn().mockResolvedValue( + records.map((row) => ({ + costId: row.costId, + id: row.id, + orgId: row.orgId, + })) + ), }, }, vtex: { @@ -20,15 +56,6 @@ const makeCtx = (records: any[], account = 'acc'): any => ({ }, }) -const record = (overrides: Record) => ({ - costCenterName: 'CC', - costId: 'cc1', - id: 'r1', - orgId: 'org1', - organizationStatus: 'active', - ...overrides, -}) - // The module keeps a per-email in-memory cache, so each test uses its own // email to stay isolated. let uniq = 0 @@ -40,7 +67,12 @@ describe('getUserOrganizationsData', () => { // adopted: its costId is what gets stamped on the session, so the pair // would be broken. The usable pair further down the list wins. const ctx = makeCtx([ - record({ costCenterName: null, costId: 'ccGone', id: 'rA', orgId: 'orgA' }), + record({ + costCenterName: null, + costId: 'ccGone', + id: 'rA', + orgId: 'orgA', + }), record({ costId: 'ccB', id: 'rB', orgId: 'orgB' }), ]) @@ -56,7 +88,12 @@ describe('getUserOrganizationsData', () => { // One record has the organization, the other has the cost center - but no // single record has both, and the costId comes from the nominated record. const ctx = makeCtx([ - record({ costCenterName: null, id: 'rA', orgId: 'orgA' }), + record({ + costCenterName: null, + costId: 'ccGone', + id: 'rA', + orgId: 'orgA', + }), record({ id: 'rB', organizationStatus: 'inactive', orgId: 'orgB' }), ]) @@ -89,8 +126,6 @@ describe('getUserOrganizationsData', () => { expect(first.activeOrganization).toMatchObject({ orgId: 'orgA' }) expect(second.activeOrganization).toMatchObject({ orgId: 'orgB' }) // And the second call must have hit its own origin, not account A's cache. - expect( - ctxB.clients.organizations.getOrganizationsPaginatedByEmail - ).toHaveBeenCalled() + expect(ctxB.clients.masterDataExtended.searchDocuments).toHaveBeenCalled() }) }) diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts index 3f39e294..f32fe4d1 100644 --- a/node/__tests__/setProfile.test.ts +++ b/node/__tests__/setProfile.test.ts @@ -55,6 +55,20 @@ const defaultAddress = { postalCode: '12345', } +const costCenterDoc = ( + addresses: any[] = [defaultAddress], + overrides: Record = {} +) => ({ + addresses, + businessDocument: null, + name: 'CC', + organization: 'org1', + phoneNumber: null, + sellers: null, + stateRegistration: null, + ...overrides, +}) + const makeCtx = (scenario: Scenario = {}) => { const { appSettings = {}, @@ -101,6 +115,14 @@ const makeCtx = (scenario: Scenario = {}) => { // example the b2b_users record id 'u2') returns undefined, exactly like // Master Data would - which is how the wrong-id lookup bug is caught. getDocumentById: jest.fn().mockImplementation((entity, id) => { + if (entity === 'cost_centers') { + return Promise.resolve(costCenterDoc(costCenterAddresses, { id })) + } + + // Deliberately id-exact: the recovered organization only resolves for + // its real organization id ('org2'). Fetching with any other id (for + // example the b2b_users record id 'u2') returns undefined, exactly like + // Master Data would - which is how the wrong-id lookup bug is caught. if (entity !== 'organizations') { return Promise.resolve(undefined) } @@ -148,7 +170,8 @@ const makeCtx = (scenario: Scenario = {}) => { matching = matching.filter((doc: any) => !doc.active) } - const { page = 1, pageSize = 50 } = pagination ?? {} + const page: number = pagination?.page ?? 1 + const pageSize: number = pagination?.pageSize ?? 50 const start = (page - 1) * pageSize return Promise.resolve({ @@ -161,21 +184,9 @@ const makeCtx = (scenario: Scenario = {}) => { getB2BSettings: jest.fn().mockResolvedValue({ data: { getB2BSettings: { uiSettings: { clearCart: false } } }, }), - getCostCenterById: jest.fn().mockResolvedValue({ - data: { - getCostCenterById: { - addresses: costCenterAddresses, - businessDocument: null, - phoneNumber: null, - sellers: null, - stateRegistration: null, - }, - }, - }), getMarketingTags: jest .fn() .mockResolvedValue({ data: { getMarketingTags: { tags: [] } } }), - getOrganizationsByEmail: jest.fn(), }, profileSystem: {}, salesChannel: { @@ -344,8 +355,10 @@ describe('setProfile', () => { // inactive one the stored selection points at. expect(response['storefront-permissions'].organization.value).toBe('org2') expect(response['storefront-permissions'].costcenter.value).toBe('cost2') - expect(ctx.clients.organizations.getCostCenterById).toHaveBeenCalledWith( - 'cost2' + expect(ctx.clients.masterDataExtended.getDocumentById).toHaveBeenCalledWith( + 'cost_centers', + 'cost2', + expect.any(Array) ) // The record id follows the adopted pair: the emitted userId must agree @@ -507,6 +520,10 @@ describe('setProfile', () => { // The sticky organization must resolve as usable too. ctx.clients.masterDataExtended.getDocumentById.mockImplementation( (entity: string, id: string) => { + if (entity === 'cost_centers') { + return Promise.resolve(costCenterDoc([defaultAddress], { id })) + } + if (entity !== 'organizations') { return Promise.resolve(undefined) } @@ -601,6 +618,14 @@ describe('setProfile', () => { ctx.clients.masterDataExtended.getDocumentById.mockImplementation( (entity: string, id: string) => { + if (entity === 'cost_centers') { + if (id === 'costGone') { + return Promise.resolve(undefined) + } + + return Promise.resolve(costCenterDoc([defaultAddress], { id })) + } + if (entity !== 'organizations') { return Promise.resolve(undefined) } @@ -629,35 +654,6 @@ describe('setProfile', () => { } ) - // The pinned record's cost center was deleted: Master Data answers a - // document whose fields are all null. - ctx.clients.organizations.getCostCenterById.mockImplementation( - (id: string) => - id === 'costGone' - ? Promise.resolve({ - data: { - getCostCenterById: { - addresses: null, - businessDocument: null, - phoneNumber: null, - sellers: null, - stateRegistration: null, - }, - }, - }) - : Promise.resolve({ - data: { - getCostCenterById: { - addresses: [defaultAddress], - businessDocument: null, - phoneNumber: null, - sellers: null, - stateRegistration: null, - }, - }, - }) - ) - const response = await run(ctx, { ...makeBody(), 'storefront-permissions': { @@ -1232,8 +1228,7 @@ describe('setProfile', () => { await run(ctx) - const sent = - ctx.clients.checkout.updateOrderFormShipping.mock.calls[0]?.[1] + const sent = ctx.clients.checkout.updateOrderFormShipping.mock.calls[0]?.[1] // Checkout must receive the cleaned value, otherwise it answers CHK0040 and // discards the whole attachment, leaving the previous address on the cart. @@ -1272,8 +1267,7 @@ describe('setProfile', () => { }) // Never rewritten: a stripped postal code is a different location. - const sent = - ctx.clients.checkout.updateOrderFormShipping.mock.calls[0]?.[1] + const sent = ctx.clients.checkout.updateOrderFormShipping.mock.calls[0]?.[1] expect(sent.address.postalCode).toBe('12345%') }) @@ -1301,9 +1295,7 @@ describe('setProfile', () => { it('tags a cart address update that still fails after sanitizing', async () => { const ctx = makeCtx({ - costCenterAddresses: [ - { ...defaultAddress, reference: 'has "quotes"' }, - ], + costCenterAddresses: [{ ...defaultAddress, reference: 'has "quotes"' }], }) // Shaped like a real axios rejection: `config.data` carries the request diff --git a/node/clients/Organizations.ts b/node/clients/Organizations.ts index 5c74c72c..b422a918 100644 --- a/node/clients/Organizations.ts +++ b/node/clients/Organizations.ts @@ -1,13 +1,8 @@ -import type { GraphQLResponse, InstanceOptions, IOContext } from '@vtex/api' +import type { InstanceOptions, IOContext } from '@vtex/api' import { AppGraphQLClient } from '@vtex/api' import { QUERIES } from '../resolvers/Routes/utils' import { getTokenToHeader } from './index' -import type { - GetCostCenterType, - GetOrganizationsByEmailResponse, - GetOrganizationsPaginatedByEmailResponse, -} from '../typings/custom' const getPersistedQuery = () => { return { @@ -23,16 +18,6 @@ export class OrganizationsGraphQLClient extends AppGraphQLClient { super('vtex.b2b-organizations-graphql@2.x', ctx, options) } - public getOrganizationById = async (orgId: string): Promise => { - return this.query({ - extensions: getPersistedQuery(), - query: QUERIES.getOrganizationById, - variables: { - id: orgId, - }, - }) - } - public getB2BSettings = async (): Promise => { return this.query({ extensions: getPersistedQuery(), @@ -41,16 +26,6 @@ export class OrganizationsGraphQLClient extends AppGraphQLClient { }) } - public getCostCenterById = async (costId: string) => { - return this.query({ - extensions: getPersistedQuery(), - query: QUERIES.getCostCenterById, - variables: { - id: costId, - }, - }) as Promise> - } - public getMarketingTags = async (costId: string): Promise => { return this.query({ extensions: getPersistedQuery(), @@ -61,34 +36,6 @@ export class OrganizationsGraphQLClient extends AppGraphQLClient { }) } - public getOrganizationsByEmail = async (email: string) => { - return this.query({ - extensions: getPersistedQuery(), - query: QUERIES.getOrganizationsByEmail, - variables: { email }, - }) as Promise - } - - public getOrganizationsPaginatedByEmail = async ( - email: string, - page: number, - pageSize: number - ) => { - return this.query({ - extensions: getPersistedQuery(), - query: QUERIES.getOrganizationsPaginatedByEmail, - variables: { - email, - page, - pageSize, - }, - }) as Promise<{ - data: { - getOrganizationsPaginatedByEmail: GetOrganizationsPaginatedByEmailResponse - } - }> - } - private query = async (param: { query: string variables: any diff --git a/node/clients/masterDataExtended.ts b/node/clients/masterDataExtended.ts index 95c5cd6c..7c2a00bc 100644 --- a/node/clients/masterDataExtended.ts +++ b/node/clients/masterDataExtended.ts @@ -24,6 +24,55 @@ export class MasterDataExtended extends JanusClient { this.http.get( `/api/dataentities/${dataEntity}/documents/${id}?_fields=${fields.join( ',' - )}` + )}`, + { + metric: 'masterdata-get-document', + } ) + + public searchDocuments = (params: { + dataEntity: string + fields: string[] + where?: string + schema?: string + sort?: string + pagination?: { page: number; pageSize: number } + }) => { + const { + dataEntity, + fields, + where, + schema, + sort, + pagination = { page: 1, pageSize: 50 }, + } = params + + const from = (pagination.page - 1) * pagination.pageSize + const to = from + pagination.pageSize - 1 + const query = new URLSearchParams({ + _fields: fields.join(','), + }) + + if (where) { + query.set('_where', where) + } + + if (schema) { + query.set('_schema', schema) + } + + if (sort) { + query.set('_sort', sort) + } + + return this.http.get( + `/api/dataentities/${dataEntity}/search?${query.toString()}`, + { + headers: { + 'REST-Range': `resources=${from}-${to}`, + }, + metric: 'masterdata-search', + } + ) + } } diff --git a/node/resolvers/Mutations/Users.ts b/node/resolvers/Mutations/Users.ts index 2f7541cc..3613158e 100644 --- a/node/resolvers/Mutations/Users.ts +++ b/node/resolvers/Mutations/Users.ts @@ -1,7 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { currentSchema } from '../../utils' import { describeClientError } from '../../utils/clientError' -import { CUSTOMER_SCHEMA_NAME } from '../../utils/constants' +import { + COST_CENTER_DATA_ENTITY, + COST_CENTER_FIELDS, + CUSTOMER_SCHEMA_NAME, +} from '../../utils/constants' import type { ChangeTeamParams } from '../../utils/metrics/changeTeam' import { sendChangeTeamMetric } from '../../utils/metrics/changeTeam' import { @@ -238,17 +242,17 @@ export const addUser = async (_: any, params: any, ctx: Context) => { } = ctx try { - const costCenter = await ctx.clients.organizations.getCostCenterById( - params.costId - ) + const costCenter: { name?: string; organization?: string } = + await ctx.clients.masterDataExtended.getDocumentById( + COST_CENTER_DATA_ENTITY, + params.costId, + COST_CENTER_FIELDS + ) // before adding an user to a cost center we check if the cost // center exists and if it has a valid name, otherwise both // login and UI might break. - if ( - !costCenter?.data?.getCostCenterById?.name || - params.orgId !== costCenter?.data?.getCostCenterById?.organization - ) { + if (!costCenter?.name || params.orgId !== costCenter?.organization) { throw new Error(`Invalid cost center`) } diff --git a/node/resolvers/Routes/index.ts b/node/resolvers/Routes/index.ts index 761515e5..d7d88370 100644 --- a/node/resolvers/Routes/index.ts +++ b/node/resolvers/Routes/index.ts @@ -18,6 +18,12 @@ import { getCachedSessionWatcher } from '../../services/sessionWatcherCache' import { toHash } from '../../utils' import { sanitizeAddressForCheckout } from '../../utils/checkoutAddress' import { describeClientError } from '../../utils/clientError' +import { + COST_CENTER_DATA_ENTITY, + COST_CENTER_FIELDS, + ORGANIZATION_DATA_ENTITY, + ORGANIZATION_FIELDS, +} from '../../utils/constants' import { sendObservabilityEvent } from '../../utils/observabilityEvent' import { isKnownOrganizationStatus, @@ -327,6 +333,7 @@ export const Routes = { 'getSalesChannel', getCachedSalesChannel(ctx) ) + // b2bSettings is only consumed by the (conditional) clearCart branch, so it // may never be awaited; guard against unhandled rejections. const b2bSettingsPromise = timer @@ -335,10 +342,14 @@ export const Routes = { getCachedB2BSettings(ctx, () => organizations.getB2BSettings()) ) .catch((error) => { - logger.error({ error: describeClientError(error), message: 'setProfile.getB2BSettings' }) + logger.error({ + error: describeClientError(error), + message: 'setProfile.getB2BSettings', + }) return null }) + const appSettingsPromise = timer.track( 'getCachedAppSettings', getCachedAppSettings(ctx) @@ -430,15 +441,7 @@ export const Routes = { const getOrganization = async (orgId: any): Promise => { return getCachedOrganization(ctx, String(orgId), () => masterDataExtended - .getDocumentById('organizations', orgId, [ - 'name', - 'tradeName', - 'status', - 'priceTables', - 'salesChannel', - 'collections', - 'sellers', - ]) + .getDocumentById(ORGANIZATION_DATA_ENTITY, orgId, ORGANIZATION_FIELDS) .then((document: any) => { // Master Data answers a missing document with an empty result // rather than an error. Throwing keeps the miss out of both cache @@ -485,6 +488,46 @@ export const Routes = { throw error }) + const getCostCenter = async (costId: any): Promise => { + return getCachedCostCenter(ctx, String(costId), () => + masterDataExtended + .getDocumentById(COST_CENTER_DATA_ENTITY, costId, COST_CENTER_FIELDS) + .then((document: any) => { + if (!document) { + const notFound: any = new Error('costCenterNotFound') + + notFound.costCenterNotFound = true + throw notFound + } + + return document + }) + .catch((error) => { + if (!error?.costCenterNotFound) { + logger.error({ + error: describeClientError(error), + message: 'setProfile.getCostCenterById', + }) + } + + throw error + }) + ) + } + + const getCostCenterOrNull = async (costId: any): Promise => + getCostCenter(costId).catch((error: any) => { + const status = error?.response?.status ?? error?.status + + if (error?.costCenterNotFound || status === 404) { + return null + } + + throw error + }) + + const isCostCenterValid = (costCenter: any) => Boolean(costCenter?.name) + // Reassigned by the inactive-organization fallback below. const hash = toHash(`${user.orgId}|${user.costId}`) let hashChanged = body?.['storefront-permissions']?.hash?.value !== hash @@ -509,12 +552,7 @@ export const Routes = { const [organizationResponse, initialCostCenterResponse] = await Promise.all( [ timer.track('getOrganization', getOrganizationOrNull(user.orgId)), - timer.track( - 'getCostCenterById', - getCachedCostCenter(ctx, String(resolvedCostId), () => - organizations.getCostCenterById(resolvedCostId) - ) - ), + timer.track('getCostCenterById', getCostCenterOrNull(resolvedCostId)), ] ) @@ -532,48 +570,24 @@ export const Routes = { // Hand the account's limits to the middleware that emits the timings. timer.meta.sampleRate = (appSettings as any)?.sessionTimingsSampleRate - timer.meta.slowThresholdMs = (appSettings as any) - ?.sessionTimingsSlowThresholdMs - - // in case the cost center is not found, we need to find a valid cost center for the user - if ( - Object.values(costCenterResponse.data?.getCostCenterById ?? {}).every( - (value) => value === null - ) - ) { - try { - const usersByEmail = await timer.track( - 'getOrganizationsByEmail', - organizations.getOrganizationsByEmail(email) - ) - - // when cost center comes without a name, it's because the cost center is deleted - const usersData = usersByEmail.data.getOrganizationsByEmail.find( - (userByEmail) => userByEmail.costCenterName !== null - ) - - user.costId = usersData?.costId ?? user.costId - } catch (error) { - logger.error({ - error: describeClientError(error), - message: 'setProfile.graphqlGetOrganizationById', - }) - } - } + timer.meta.slowThresholdMs = ( + appSettings as any + )?.sessionTimingsSlowThresholdMs let organization: any = organizationResponse let userOrgsData: any = null - // Check if we need to fetch user organizations (for inactive org or invalid cost center) - const costCenterInvalid = Object.values( - costCenterResponse.data?.getCostCenterById ?? {} - ).every((value) => value === null) + // A missing document, or one with no name (deleted cost center), cannot + // be stamped on the session. GraphQL used to answer the latter as an + // object whose fields were all null. + const costCenterInvalid = !isCostCenterValid(costCenterResponse) // Null means the lookup 404'd: the record points at an organization that // no longer exists. Both states are unusable and share the same recovery. const organizationMissing = !organization const organizationInactive = !organizationMissing && !isOrganizationUsable(organization?.status) + const organizationUnusable = organizationMissing || organizationInactive if ( @@ -586,6 +600,7 @@ export const Routes = { status: organization?.status, }) } + const needsOrgData = organizationUnusable || costCenterInvalid if (needsOrgData) { @@ -602,9 +617,18 @@ export const Routes = { ) } - // Handle invalid cost center first + // Handle invalid cost center first. Refetch when the organization itself + // is still usable: otherwise the sellers/addresses below would still come + // from the missing document. The inactive-org branch refetches on its own. if (costCenterInvalid && userOrgsData?.validCostCenterId) { user.costId = userOrgsData.validCostCenterId + + if (!organizationUnusable) { + costCenterResponse = await timer.track( + 'getCostCenterById.invalidFallback', + getCostCenterOrNull(user.costId) + ) + } } // Handle an organization that is inactive or no longer exists. @@ -653,14 +677,10 @@ export const Routes = { // cost center that no longer exists. const stickyCostCenter = await timer.track( 'getCostCenterById.stickyValidation', - getCachedCostCenter(ctx, String(stickyRecord.costId), () => - organizations.getCostCenterById(stickyRecord.costId) - ) + getCostCenterOrNull(stickyRecord.costId) ) - const stickyCostCenterValid = !Object.values( - stickyCostCenter?.data?.getCostCenterById ?? {} - ).every((value) => value === null) + const stickyCostCenterValid = isCostCenterValid(stickyCostCenter) if (stickyCostCenterValid) { validOrganization = { @@ -728,9 +748,7 @@ export const Routes = { costCenterResponse = await timer.track( 'getCostCenterById.inactiveFallback', - getCachedCostCenter(ctx, String(fallbackCostId), () => - organizations.getCostCenterById(fallbackCostId) - ) + getCostCenterOrNull(fallbackCostId) ) // Visible on purpose: the shopper's stored selection points at an @@ -840,8 +858,7 @@ export const Routes = { } const orgSellers = organization.sellers - const costCenterSellers = - costCenterResponse?.data?.getCostCenterById?.sellers + const costCenterSellers = costCenterResponse?.sellers const sellersArray = Array.isArray(costCenterSellers) ? costCenterSellers @@ -856,6 +873,7 @@ export const Routes = { // second, uncached getAppSettings round-trip on the sellers path. const disableSellersNameFacets = (appSettings as any) ?.disableSellersNameFacets + const disablePrivateSellersFacets = (appSettings as any) ?.disablePrivateSellersFacets @@ -882,7 +900,7 @@ export const Routes = { response.public.facets.value = facets ? `${facets.join(';')};` : null response['storefront-permissions'].costcenter.value = user.costId - const costCenterData = costCenterResponse?.data?.getCostCenterById + const costCenterData = costCenterResponse phoneNumber = costCenterData?.phoneNumber diff --git a/node/resolvers/Routes/utils/index.ts b/node/resolvers/Routes/utils/index.ts index bc06f650..36cf27ec 100644 --- a/node/resolvers/Routes/utils/index.ts +++ b/node/resolvers/Routes/utils/index.ts @@ -1,11 +1,22 @@ import type { GetOrganizationByEmailBase } from '../../../typings/custom' +import { currentSchema } from '../../../utils' import { describeClientError } from '../../../utils/clientError' +import { + COST_CENTER_DATA_ENTITY, + ORGANIZATION_DATA_ENTITY, +} from '../../../utils/constants' import { isKnownOrganizationStatus, isOrganizationUsable, } from '../../../utils/organizationStatus' import { getUserById } from '../../Queries/Users' +const B2B_USERS_SCHEMA = currentSchema('b2b_users') as any + +const USER_ORG_FIELDS = ['id', 'orgId', 'costId'] +const MD_SEARCH_PAGE_SIZE = 100 +const MD_SEARCH_MAX_PAGES = 5 + // Simple in-memory cache with TTL const organizationsCache = new Map() @@ -34,39 +45,6 @@ export const QUERIES = { } } }`, - getCostCenterById: `query Costcenter($id: ID!) { - getCostCenterById(id: $id) { - paymentTerms { - id - name - } - name - organization - addresses { - addressId - addressType - addressQuery - postalCode - country - receiverName - city - state - street - number - complement - neighborhood - geoCoordinates - reference - } - phoneNumber - businessDocument - stateRegistration - sellers { - id - name - } - } - }`, getMarketingTags: ` query ($costId: ID!) { getMarketingTags(costId: $costId){ @@ -74,51 +52,6 @@ export const QUERIES = { } } `, - getOrganizationById: `query Organization($id: ID!){ - getOrganizationById(id: $id){ - name - tradeName - status - priceTables - salesChannel - sellers { - id - name - } - collections { - id - } - } - }`, - getOrganizationsByEmail: `query Organizations($email: String!) { - getOrganizationsByEmail(email: $email){ - id - organizationStatus - costId - orgId - costCenterName - } - }`, - getOrganizationsPaginatedByEmail: `query OrganizationsPaginated($email: String!, $page: Int, $pageSize: Int) { - getOrganizationsPaginatedByEmail( - email: $email - page: $page - pageSize: $pageSize - ) { - data { - id - organizationStatus - costId - orgId - costCenterName - } - pagination { - page - pageSize - total - } - } - }`, } export const generateClUser = async ({ @@ -185,6 +118,174 @@ export const generateClUser = async ({ return clUser } +interface UserOrgProfile { + costId: string + id: string + orgId: string +} + +const getDocumentOrNull = async ( + getDocument: () => Promise, + logger: Context['vtex']['logger'], + message: string +): Promise => { + try { + return await getDocument() + } catch (error) { + if ((error as ErrorResponse)?.response?.status !== 404) { + logger.error({ error: describeClientError(error), message }) + } + + return null + } +} + +const toUserOrgRow = ( + user: UserOrgProfile, + costCenterNames: Map, + organizationStatuses: Map +): GetOrganizationByEmailBase => ({ + costCenterName: costCenterNames.get(user.costId) ?? null, + costId: user.costId, + id: user.id, + orgId: user.orgId, + organizationStatus: organizationStatuses.get(user.orgId) ?? '', +}) + +/** + * Lists the caller's `b2b_users` profiles for an email and hydrates + * `costCenterName` / `organizationStatus` from Master Data (`cost_centers`, + * `organizations`). Replaces the GraphQL hop through + * `b2b-organizations-graphql` (which called back into this app). + */ +export const listUserOrganizationsByEmail = async ( + email: string, + ctx: Context +): Promise => { + const { + clients: { masterDataExtended }, + vtex: { logger }, + } = ctx + + const costCenterNames = new Map() + const organizationStatuses = new Map() + const users: UserOrgProfile[] = [] + + const hydrateNewIds = async (batch: UserOrgProfile[]) => { + const newCostIds = [ + ...new Set( + batch + .map((user) => user.costId) + .filter((id) => id && !costCenterNames.has(id)) + ), + ] + + const newOrgIds = [ + ...new Set( + batch + .map((user) => user.orgId) + .filter((id) => id && !organizationStatuses.has(id)) + ), + ] + + await Promise.all([ + ...newCostIds.map(async (id) => { + const costCenter: { name?: string | null } | null = + await getDocumentOrNull( + () => + masterDataExtended.getDocumentById(COST_CENTER_DATA_ENTITY, id, [ + 'id', + 'name', + ]), + logger, + 'listUserOrganizationsByEmail.getCostCenter' + ) + + costCenterNames.set(id, costCenter ? costCenter.name ?? null : null) + }), + ...newOrgIds.map(async (id) => { + const organization: { status?: string | null } | null = + await getDocumentOrNull( + () => + masterDataExtended.getDocumentById(ORGANIZATION_DATA_ENTITY, id, [ + 'id', + 'status', + ]), + logger, + 'listUserOrganizationsByEmail.getOrganization' + ) + + organizationStatuses.set( + id, + organization ? organization.status ?? null : null + ) + }), + ]) + } + + const searchPage = (page: number) => + masterDataExtended.searchDocuments({ + dataEntity: B2B_USERS_SCHEMA.name, + fields: USER_ORG_FIELDS, + pagination: { page, pageSize: MD_SEARCH_PAGE_SIZE }, + schema: B2B_USERS_SCHEMA.version, + where: `email=${email}`, + }) + + const firstPage = await searchPage(1) + + if (!firstPage?.length) { + return [] + } + + users.push(...firstPage) + await hydrateNewIds(firstPage) + + const hasValidCostCenter = users.some( + (user) => (costCenterNames.get(user.costId) ?? null) !== null + ) + + const hasActiveOrg = users.some((user) => + isAdoptableRecord(toUserOrgRow(user, costCenterNames, organizationStatuses)) + ) + + if ( + firstPage.length === MD_SEARCH_PAGE_SIZE && + (!hasValidCostCenter || !hasActiveOrg) + ) { + const remainingPages = Array.from( + { length: MD_SEARCH_MAX_PAGES - 1 }, + (_, i) => i + 2 + ) + + const additionalBatches = await Promise.all( + remainingPages.map((page) => + searchPage(page).catch((error) => { + logger.warn({ + error: describeClientError(error), + message: 'Failed to fetch page', + page, + }) + + return [] as UserOrgProfile[] + }) + ) + ) + + const extraUsers = additionalBatches.reduce( + (acc, batch) => acc.concat(batch), + [] as UserOrgProfile[] + ) + + users.push(...extraUsers) + await hydrateNewIds(extraUsers) + } + + return users.map((user) => + toUserOrgRow(user, costCenterNames, organizationStatuses) + ) +} + /** * Unified method to get user organizations data with caching. * Fetches all organizations for an email and returns relevant data for different validations. @@ -220,70 +321,13 @@ export const getUserOrganizationsData = async ( } } - const { organizations } = ctx.clients const { vtex: { logger }, } = ctx try { - const firstResponse = await organizations.getOrganizationsPaginatedByEmail( - email, - 1, - 200 // Most users have < 200 orgs, this usually gets everything in one call - ) - - const { data: firstPageData, pagination } = - firstResponse?.data?.getOrganizationsPaginatedByEmail || {} + const allOrganizations = await listUserOrganizationsByEmail(email, ctx) - if (!firstPageData?.length) { - return { validCostCenterId: null, activeOrganization: null } - } - - const allOrganizations = [...firstPageData] - - // Only paginate if there are more pages and we haven't found both values - const totalPages = Math.ceil((pagination?.total || 0) / 200) - - if (totalPages > 1) { - // Check if we already have what we need from first page - const hasValidCostCenter = firstPageData.some( - (org) => org.costCenterName !== null - ) - - const hasActiveOrg = firstPageData.some(isAdoptableRecord) - - // Only fetch more pages if we're missing data - if (!hasValidCostCenter || !hasActiveOrg) { - // Fetch remaining pages in parallel for better performance - const remainingPages = Array.from( - { length: Math.min(totalPages - 1, 4) }, // Limit to 5 total pages max - (_, i) => i + 2 - ) - - const additionalResponses = await Promise.all( - remainingPages.map((page) => - organizations - .getOrganizationsPaginatedByEmail(email, page, 200) - .catch((error) => { - logger.warn({ error: describeClientError(error), message: 'Failed to fetch page', page }) - - return null - }) - ) - ) - - // Combine all results - for (const response of additionalResponses) { - if (response?.data?.getOrganizationsPaginatedByEmail?.data) { - allOrganizations.push( - ...response.data.getOrganizationsPaginatedByEmail.data - ) - } - } - } - } - - // Find required values from all organizations const validCostCenterOrg = allOrganizations.find( (org) => org.costCenterName !== null ) @@ -310,8 +354,8 @@ export const getUserOrganizationsData = async ( } const result = { - validCostCenterId: validCostCenterOrg?.costId || null, - activeOrganization: activeOrg || null, + activeOrganization: activeOrg ?? null, + validCostCenterId: validCostCenterOrg?.costId ?? null, } // Cache the result @@ -336,11 +380,11 @@ export const getUserOrganizationsData = async ( return result } catch (error) { logger.error({ + email, error: describeClientError(error), message: 'getUserOrganizationsData.error', - email, }) - return { validCostCenterId: null, activeOrganization: null } + return { activeOrganization: null, validCostCenterId: null } } } diff --git a/node/services/organizationsCache.ts b/node/services/organizationsCache.ts index 72576e8d..06bcf471 100644 --- a/node/services/organizationsCache.ts +++ b/node/services/organizationsCache.ts @@ -9,10 +9,12 @@ import { import { createCachedResource } from './cache' /** - * These are all cross-app calls into vtex.b2b-organizations / Master Data. Once - * the account-level lookups were cached they became the most expensive remaining - * steps of the session transform (getCostCenterById alone measured around a - * second), and the transform runs several times per navigation. + * These are all Master Data (or, for B2B settings, cross-app GraphQL) lookups. + * Once the account-level lookups were cached they became the most expensive + * remaining steps of the session transform (the GraphQL getCostCenterById hop + * alone measured around a second), and the transform runs several times per + * navigation. Cost center documents are now read the same way as organizations: + * `masterDataExtended.getDocumentById`, with this cache in front. * * Both layers are used: warm pods do no I/O, and cold pods read the entry a * sibling pod already populated instead of paying the cross-app cost. diff --git a/node/utils/constants.ts b/node/utils/constants.ts index 933e80d5..a3f5cb0f 100644 --- a/node/utils/constants.ts +++ b/node/utils/constants.ts @@ -1,4 +1,32 @@ export const CUSTOMER_SCHEMA_NAME = 'CL' + +// Entity + schema versions owned by vtex.b2b-organizations-graphql (node/mdSchema.ts). +// This app only GET-by-id those entities (no `_schema` on the request). +export const COST_CENTER_DATA_ENTITY = 'cost_centers' +export const COST_CENTER_SCHEMA_VERSION = 'v0.0.8' +export const COST_CENTER_FIELDS = [ + 'id', + 'name', + 'addresses', + 'paymentTerms', + 'organization', + 'phoneNumber', + 'businessDocument', + 'stateRegistration', + 'sellers', +] +export const ORGANIZATION_DATA_ENTITY = 'organizations' +export const ORGANIZATION_SCHEMA_VERSION = 'v0.0.8' +export const ORGANIZATION_FIELDS = [ + 'id', + 'name', + 'tradeName', + 'status', + 'priceTables', + 'salesChannel', + 'collections', + 'sellers', +] export const CUSTOMER_REQUIRED_FIELDS = [ 'email', 'id', From 285c754525d6e36489a70361940f5342e126253f Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Wed, 26 Aug 2026 16:17:05 -0400 Subject: [PATCH 18/19] Additional performance adjustments --- .eslintrc | 13 +- CHANGELOG.md | 6 + docs/PERFORMANCE_AND_CACHING.md | 4 +- node/__tests__/checkPermissions.test.ts | 10 +- node/__tests__/checkoutAddress.test.ts | 41 +- node/__tests__/clientError.test.ts | 5 +- node/__tests__/graphqlHopTimings.test.ts | 165 ++++++++ node/__tests__/requestTimings.test.ts | 29 +- .../setActiveUserByOrganization.test.ts | 109 +++++ node/__tests__/setProfile.test.ts | 12 +- .../staleFromVBaseWhileRevalidate.test.ts | 4 +- node/__tests__/withRequestTimings.test.ts | 32 +- node/clients/checkout.ts | 1 - node/directives/withSender.ts | 1 - node/directives/withSession.ts | 1 - node/directives/withUserPermissions.ts | 1 - node/index.ts | 2 +- node/jest.config.js | 3 +- node/middlewares/withRequestTimings.ts | 38 +- node/resolvers/Mutations/Users.ts | 198 ++++++--- node/resolvers/Queries/Users.ts | 393 ++++++++++++------ node/services/activeUserCache.ts | 11 +- node/services/appSettingsCache.ts | 4 +- node/utils/clientError.ts | 3 +- node/utils/constants.ts | 7 +- node/utils/cookie.ts | 2 +- node/utils/requestTimings.ts | 37 +- node/utils/staleFromVBaseWhileRevalidate.ts | 1 - 28 files changed, 875 insertions(+), 258 deletions(-) create mode 100644 node/__tests__/graphqlHopTimings.test.ts create mode 100644 node/__tests__/setActiveUserByOrganization.test.ts diff --git a/.eslintrc b/.eslintrc index 5c23ed82..ca0af8b0 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,6 +5,15 @@ "node": true }, "rules": { - "@typescript-eslint/no-explicit-any": "off" - } + "@typescript-eslint/no-explicit-any": "off", + "max-params": "off" + }, + "overrides": [ + { + "files": ["node/typings/**/*.ts"], + "rules": { + "@typescript-eslint/no-unused-vars": "off" + } + } + ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5986d330..2b9db16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Changed + +- GraphQL `checkUserPermission` / `getUserByEmail` reuse the memory `active-user-permissions` cache already used by the REST `checkPermissions` route (TTL 5 minutes), so sibling B2B apps (and repeated hops in the same navigation) no longer hit Master Data on every call. +- `getAllUsers` fetches page 1 with the fields callers need and only requests further pages when `total` exceeds the page size. The previous count probe (`fields: ['id']`) then re-fetched page 1 in full, doubling the cost of every small search — including the `active=true` lookup that returns 0..1 records. +- `setProfile.timings` is emitted on every transform while diagnosing session latency. Production still has the slow/sampled logger path behind `sessionTimingsSlowThresholdMs`; this always-on trace is temporary. + ## [3.8.0] - 2026-08-26 ## [3.7.0] - 2026-08-26 diff --git a/docs/PERFORMANCE_AND_CACHING.md b/docs/PERFORMANCE_AND_CACHING.md index 8e555c68..c2c1faa1 100644 --- a/docs/PERFORMANCE_AND_CACHING.md +++ b/docs/PERFORMANCE_AND_CACHING.md @@ -52,7 +52,7 @@ Corollary for reviews: when a change touches a fetcher or anything downstream of | `organization` | Master Data | both | 60s | 2min | 10000 | orgId | | `cost-center` | Master Data | both | 60s | 2min | **8MB byte budget** | costId | | `active-user` | Master Data (paginated) | both | 5min¹ | 5min | 10000 | `email\|b2bCurrentCostCenter` | -| `active-user-permissions` | Master Data (paginated) | memory only | 60s | — | 10000 | email | +| `active-user-permissions` | Master Data (paginated) | memory only | 5min | — | 10000 | email | | `region` | checkout REST | both | 30min | 30min | 10000 | `country\|postalCode\|sc\|geo` | | `session-watcher` | VBase | memory only | 60s | — | 100 | `active` | | `roles` | VBase (MD fallback) | memory only | 60s | — | 100 | `all` | @@ -67,7 +67,7 @@ Corollary for reviews: when a change touches a fetcher or anything downstream of - **Session watcher (60s):** it is the operational kill switch; disabling it must bite quickly. - **Roles (60s):** authorization data. Role mutations write VBase but cannot invalidate other pods' memory caches, so this TTL is the upper bound on how long a revoked permission stays effective. - **Active user:** the TTL is only a safety net. The cache key contains the session's `public.b2bCurrentCostCenter`, which `setCurrentOrganization` writes on every organization switch — so a switch changes the key and misses the cache immediately, regardless of TTL. The TTL covers changes that bypass that mutation, such as an admin editing a user's organizations directly. -- **`active-user-permissions` (60s, memory only):** the `checkPermissions` route receives only `app` + `email`, so there is no cost center to key on and no key-based invalidation. Short TTL bounds how long stale permissions can survive an organization switch; no VBase layer so nothing extends that window. +- **`active-user-permissions` (5min, memory only):** the REST `checkPermissions` route and the GraphQL `checkUserPermission` / `getUserByEmail` path receive only `app` + `email`, so there is no cost center to key on and no key-based invalidation. The TTL bounds how long stale permissions can survive an organization switch; no VBase layer so nothing extends that window. REST and GraphQL share this cache, so a burst of sibling-app hops in the same navigation hits Master Data once. ### Why the cost center cache is bounded by bytes diff --git a/node/__tests__/checkPermissions.test.ts b/node/__tests__/checkPermissions.test.ts index f5fc3d89..ae3205bb 100644 --- a/node/__tests__/checkPermissions.test.ts +++ b/node/__tests__/checkPermissions.test.ts @@ -97,13 +97,13 @@ describe('checkPermissions', () => { const lookups = ctx.clients.masterdata.searchDocumentsWithPaginationInfo await run(ctx) - // One resolution = two Master Data calls (count probe + one page). - expect(lookups).toHaveBeenCalledTimes(2) + // One resolution = one Master Data call (the result fits in a single page). + expect(lookups).toHaveBeenCalledTimes(1) await run(ctx) // This route is called per request by sibling B2B apps, so the second // check must be a cache hit. - expect(lookups).toHaveBeenCalledTimes(2) + expect(lookups).toHaveBeenCalledTimes(1) }) it('does not share cached users between accounts', async () => { @@ -115,10 +115,10 @@ describe('checkPermissions', () => { expect( first.clients.masterdata.searchDocumentsWithPaginationInfo - ).toHaveBeenCalledTimes(2) + ).toHaveBeenCalledTimes(1) expect( second.clients.masterdata.searchDocumentsWithPaginationInfo - ).toHaveBeenCalledTimes(2) + ).toHaveBeenCalledTimes(1) }) it('rejects requests without an app or an email', async () => { diff --git a/node/__tests__/checkoutAddress.test.ts b/node/__tests__/checkoutAddress.test.ts index 4d6b0de9..7b010bd2 100644 --- a/node/__tests__/checkoutAddress.test.ts +++ b/node/__tests__/checkoutAddress.test.ts @@ -41,7 +41,10 @@ describe('sanitizeAddressForCheckout', () => { expect(serialized).not.toContain('Apt 4') expect( [...invalid, ...sanitized].every( - (entry) => Object.keys(entry).sort().join() === 'field,removed' + (entry) => + Object.keys(entry) + .sort((left, right) => left.localeCompare(right)) + .join() === 'field,removed' ) ).toBe(true) }) @@ -54,10 +57,11 @@ describe('sanitizeAddressForCheckout', () => { reference: 'has "quotes"', }) - expect(sanitized.map(({ field }) => field).sort()).toEqual([ - 'complement', - 'reference', - ]) + expect( + sanitized + .map(({ field }) => field) + .sort((left, right) => left.localeCompare(right)) + ).toEqual(['complement', 'reference']) expect(address.complement).toBe('Suite 100') expect(address.reference).toBe('has quotes') }) @@ -76,12 +80,19 @@ describe('sanitizeAddressForCheckout', () => { street: 'MQRG+59 Nairobi', } - const { address: result, invalid, sanitized } = - sanitizeAddressForCheckout(address) + const { + address: result, + invalid, + sanitized, + } = sanitizeAddressForCheckout(address) expect(sanitized).toHaveLength(0) expect(result).toEqual(address) - expect(invalid.map(({ field }) => field).sort()).toEqual([ + expect( + invalid + .map(({ field }) => field) + .sort((left, right) => left.localeCompare(right)) + ).toEqual([ 'city', 'neighborhood', 'number', @@ -100,8 +111,11 @@ describe('sanitizeAddressForCheckout', () => { addressType: 'BillingAddress', } - const { address: result, invalid, sanitized } = - sanitizeAddressForCheckout(address) + const { + address: result, + invalid, + sanitized, + } = sanitizeAddressForCheckout(address) expect(sanitized).toHaveLength(0) expect(invalid).toHaveLength(0) @@ -115,8 +129,11 @@ describe('sanitizeAddressForCheckout', () => { // rather than ship to the wrong place. const address = { country: 'US"A', postalCode: '12345%' } - const { address: result, invalid, sanitized } = - sanitizeAddressForCheckout(address) + const { + address: result, + invalid, + sanitized, + } = sanitizeAddressForCheckout(address) expect(sanitized).toHaveLength(0) expect(result.postalCode).toBe('12345%') diff --git a/node/__tests__/clientError.test.ts b/node/__tests__/clientError.test.ts index 2f417e6b..5057e677 100644 --- a/node/__tests__/clientError.test.ts +++ b/node/__tests__/clientError.test.ts @@ -100,7 +100,10 @@ describe('describeClientError', () => { '\n' ) - const described: any = describeClientError({ message: 'x', stack: longStack }) + const described: any = describeClientError({ + message: 'x', + stack: longStack, + }) expect(described.stack.split('\n')).toHaveLength(5) }) diff --git a/node/__tests__/graphqlHopTimings.test.ts b/node/__tests__/graphqlHopTimings.test.ts new file mode 100644 index 00000000..49a2992f --- /dev/null +++ b/node/__tests__/graphqlHopTimings.test.ts @@ -0,0 +1,165 @@ +import { + checkUserPermission, + getOrganizationsByEmail, +} from '../resolvers/Queries/Users' + +process.env.VTEX_APP_ID = 'vtex.storefront-permissions@3.6.1' + +const role = { + features: [ + { features: ['add-users-organization'], module: 'vtex.b2b-organizations' }, + ], + id: 'role1', + locked: false, + name: 'Buyer', + slug: 'customer-admin', +} + +const userDoc = { + active: true, + clId: 'cl1', + costId: 'cost1', + email: 'buyer@test.com', + id: 'u1', + name: 'Buyer', + orgId: 'org1', + roleId: 'role1', +} + +let uniq = 0 + +const makeCtx = (): any => ({ + clients: { + masterdata: { + searchDocumentsWithPaginationInfo: jest.fn().mockResolvedValue({ + data: [userDoc], + pagination: { page: 1, total: 1 }, + }), + }, + vbase: { + getJSON: jest.fn().mockImplementation((bucket: string) => { + if (bucket === 'b2b_roles') { + return Promise.resolve([role]) + } + + return Promise.resolve(null) + }), + saveJSON: jest.fn().mockResolvedValue(undefined), + }, + }, + vtex: { + account: `traceacc${uniq++}`, + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + sender: 'vtex.b2b-organizations@3.x', + sessionData: { + namespaces: { + authentication: { storeUserEmail: { value: 'buyer@test.com' } }, + profile: { email: { value: 'buyer@test.com' } }, + }, + }, + workspace: 'master', + }, +}) + +describe('GraphQL hop traces', () => { + it('emits checkUserPermission.timings with getUserByEmail vs getRole', async () => { + const ctx = makeCtx() + + const result = await checkUserPermission(null, { skipError: true }, ctx) + + expect(result.permissions).toEqual(['add-users-organization']) + + const reported = ctx.vtex.logger.info.mock.calls.find( + (call: any[]) => call[0]?.message === 'checkUserPermission.timings' + ) + + expect(reported?.[0]).toMatchObject({ + impersonating: false, + module: 'vtex.b2b-organizations', + permissionCount: 1, + roleId: 'role1', + }) + expect(reported?.[0].timings).toEqual( + expect.objectContaining({ + getRole: expect.any(Number), + getUserByEmail: expect.any(Number), + }) + ) + }) + + it('emits getOrganizationsByEmail.timings with the list fan-out size', async () => { + const ctx = makeCtx() + + ctx.clients.masterdata.searchDocumentsWithPaginationInfo.mockResolvedValue({ + data: [ + userDoc, + { ...userDoc, costId: 'cost2', id: 'u2', orgId: 'org2' }, + { ...userDoc, costId: 'cost3', id: 'u3', orgId: 'org3' }, + ], + pagination: { page: 1, total: 3 }, + }) + + const result = await getOrganizationsByEmail( + null, + { email: 'buyer@test.com' }, + ctx + ) + + expect(result).toHaveLength(3) + + const reported = ctx.vtex.logger.info.mock.calls.find( + (call: any[]) => call[0]?.message === 'getOrganizationsByEmail.timings' + ) + + expect(reported?.[0]).toMatchObject({ + listedCount: 3, + searchPagesEstimate: 1, + }) + expect(reported?.[0].timings.listUsers).toEqual(expect.any(Number)) + expect( + ctx.clients.masterdata.searchDocumentsWithPaginationInfo + ).toHaveBeenCalledTimes(1) + }) + + it('serves repeated checkUserPermission for the same email from the permissions cache', async () => { + const ctx = makeCtx() + const lookups = ctx.clients.masterdata.searchDocumentsWithPaginationInfo + + await checkUserPermission(null, { skipError: true }, ctx) + expect(lookups).toHaveBeenCalledTimes(1) + + await checkUserPermission(null, { skipError: true }, ctx) + expect(lookups).toHaveBeenCalledTimes(1) + }) + + it('fetches remaining pages when the email has more than one page of records', async () => { + const ctx = makeCtx() + const page1 = Array.from({ length: 50 }, (_, i) => ({ + ...userDoc, + id: `u${i}`, + })) + + const page2 = [{ ...userDoc, id: 'u50', orgId: 'org2' }] + + ctx.clients.masterdata.searchDocumentsWithPaginationInfo + .mockResolvedValueOnce({ + data: page1, + pagination: { page: 1, total: 51 }, + }) + .mockResolvedValueOnce({ + data: page2, + pagination: { page: 2, total: 51 }, + }) + + const result = await getOrganizationsByEmail( + null, + { email: 'buyer@test.com' }, + ctx + ) + + expect(result).toHaveLength(51) + expect( + ctx.clients.masterdata.searchDocumentsWithPaginationInfo + ).toHaveBeenCalledTimes(2) + }) +}) diff --git a/node/__tests__/requestTimings.test.ts b/node/__tests__/requestTimings.test.ts index e57f5f46..54add8a1 100644 --- a/node/__tests__/requestTimings.test.ts +++ b/node/__tests__/requestTimings.test.ts @@ -1,6 +1,7 @@ import { attachTimer, createTimer, + emitTimerTrace, getTimer, logRequestTimings, } from '../utils/requestTimings' @@ -50,7 +51,7 @@ describe('logRequestTimings', () => { }) expect(logger.warn).toHaveBeenCalledTimes(1) - const payload = logger.warn.mock.calls[0][0] + const [[payload]] = logger.warn.mock.calls expect(payload.message).toBe('test.timings') expect(payload.slowestStep).toBe('slowStep') @@ -98,9 +99,33 @@ describe('logRequestTimings', () => { timer: createTimer(), }) - const payload = logger.warn.mock.calls[0][0] + const [[payload]] = logger.warn.mock.calls expect(payload.failed).toBe(true) expect(payload.orgId).toBe('org1') }) }) + +describe('emitTimerTrace', () => { + it('always logs the collected timings', async () => { + const timer = createTimer() + + await timer.track('listUsers', Promise.resolve(1)) + timer.timings.listUsers = 42 + + const logger = makeLogger() + + emitTimerTrace(logger, 'getOrganizationsByEmail.timings', timer, { + listedCount: 3, + }) + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + listedCount: 3, + message: 'getOrganizationsByEmail.timings', + slowestStep: 'listUsers', + totalMs: expect.any(Number), + }) + ) + }) +}) diff --git a/node/__tests__/setActiveUserByOrganization.test.ts b/node/__tests__/setActiveUserByOrganization.test.ts new file mode 100644 index 00000000..913fd730 --- /dev/null +++ b/node/__tests__/setActiveUserByOrganization.test.ts @@ -0,0 +1,109 @@ +import { sendMetric } from '../clients/metrics' +import { setActiveUserByOrganization } from '../resolvers/Mutations/Users' +import { getAllUsersByEmail } from '../resolvers/Queries/Users' + +jest.mock('../clients/metrics', () => ({ + B2B_METRIC_NAME: 'b2b-suite-buyerorg-data', + sendMetric: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock('../resolvers/Queries/Users', () => ({ + getAllUsersByEmail: jest.fn(), + getOrganizationsByEmail: jest.fn(), + getUserByEmailOrgIdAndCostId: jest.fn(), +})) + +const sendMetricMock = sendMetric as jest.Mock +const getAllUsersByEmailMock = getAllUsersByEmail as jest.Mock + +const targetUser = { + active: false, + costId: 'cost2', + email: 'buyer@test.com', + id: 'u2', + orgId: 'org2', +} + +const makeCtx = (): any => ({ + clients: { + masterdata: { + createOrUpdateEntireDocument: jest + .fn() + .mockResolvedValue({ DocumentId: 'u2' }), + searchDocuments: jest.fn().mockResolvedValue([targetUser]), + }, + session: { getSession: jest.fn() }, + }, + vtex: { + account: 'acc', + adminUserAuthToken: 'admin-token', + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + sessionToken: null, + workspace: 'master', + }, +}) + +describe('setActiveUserByOrganization', () => { + beforeEach(() => { + sendMetricMock.mockClear() + getAllUsersByEmailMock.mockReset() + }) + + it('traces the Master Data fan-out: full list plus a write per other record', async () => { + getAllUsersByEmailMock.mockResolvedValue([ + { ...targetUser, active: true, costId: 'cost1', id: 'u1', orgId: 'org1' }, + targetUser, + { + active: false, + costId: 'cost3', + email: 'buyer@test.com', + id: 'u3', + orgId: 'org3', + }, + ]) + + const ctx = makeCtx() + + await setActiveUserByOrganization( + null, + { costId: 'cost2', orgId: 'org2', userId: 'u2' }, + ctx + ) + + const reported = ctx.vtex.logger.info.mock.calls.find( + (call: any[]) => + call[0]?.message === 'setActiveUserByOrganization.timings' + ) + + expect(reported?.[0]).toMatchObject({ + currentlyActiveCount: 1, + deactivateWrites: 2, + listedCount: 3, + orgId: 'org2', + searchPagesEstimate: 1, + userId: 'u2', + }) + expect(reported?.[0].timings).toEqual( + expect.objectContaining({ + activate: expect.any(Number), + deactivateOthers: expect.any(Number), + getUser: expect.any(Number), + listUsers: expect.any(Number), + }) + ) + expect( + ctx.clients.masterdata.createOrUpdateEntireDocument + ).toHaveBeenCalledTimes(3) + + expect(sendMetricMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'set-active-user-by-organization', + fields: expect.objectContaining({ + currentlyActiveCount: 1, + deactivateWrites: 2, + listedCount: 3, + }), + }) + ) + }) +}) diff --git a/node/__tests__/setProfile.test.ts b/node/__tests__/setProfile.test.ts index f32fe4d1..e73fc37e 100644 --- a/node/__tests__/setProfile.test.ts +++ b/node/__tests__/setProfile.test.ts @@ -1332,7 +1332,7 @@ describe('setProfile', () => { await run(quiet) const quietPayloads = quiet.vtex.logger.info.mock.calls.filter( - (call: any[]) => call[0] && call[0]['setProfile.body'] + (call: any[]) => call[0]?.['setProfile.body'] ) expect(quietPayloads).toHaveLength(0) @@ -1342,7 +1342,7 @@ describe('setProfile', () => { await run(verbose) const verbosePayloads = verbose.vtex.logger.info.mock.calls.filter( - (call: any[]) => call[0] && call[0]['setProfile.body'] + (call: any[]) => call[0]?.['setProfile.body'] ) expect(verbosePayloads).toHaveLength(1) @@ -1355,12 +1355,12 @@ describe('setProfile', () => { const lookups = ctx.clients.masterdata.searchDocumentsWithPaginationInfo await run(ctx) - // One resolution = two Master Data calls (count probe + one page). - expect(lookups).toHaveBeenCalledTimes(2) + // One resolution = one Master Data call (the result fits in a single page). + expect(lookups).toHaveBeenCalledTimes(1) await run(ctx) // Same email, same cost center: served from cache, no new lookup. - expect(lookups).toHaveBeenCalledTimes(2) + expect(lookups).toHaveBeenCalledTimes(1) await run(ctx, { ...makeBody(), @@ -1368,7 +1368,7 @@ describe('setProfile', () => { }) // setCurrentOrganization writes b2bCurrentCostCenter on an organization // switch; a different value must change the key and force a fresh lookup. - expect(lookups).toHaveBeenCalledTimes(4) + expect(lookups).toHaveBeenCalledTimes(2) }) it('does not block the response on the CL profile update', async () => { diff --git a/node/__tests__/staleFromVBaseWhileRevalidate.test.ts b/node/__tests__/staleFromVBaseWhileRevalidate.test.ts index 90b114fb..aa0f3fcf 100644 --- a/node/__tests__/staleFromVBaseWhileRevalidate.test.ts +++ b/node/__tests__/staleFromVBaseWhileRevalidate.test.ts @@ -26,7 +26,7 @@ describe('staleFromVBaseWhileRevalidate', () => { await flush() expect(vbase.saveJSON).toHaveBeenCalledTimes(1) - const [, , saved] = vbase.saveJSON.mock.calls[0] + const [[, , saved]] = vbase.saveJSON.mock.calls expect(saved.data).toEqual({ some: 'data' }) expect(new Date(saved.ttl).getTime()).toBeGreaterThan(Date.now()) @@ -137,7 +137,7 @@ describe('staleFromVBaseWhileRevalidate', () => { await flush() expect(logger.error).toHaveBeenCalledTimes(1) - const payload = logger.error.mock.calls[0][0] + const [[payload]] = logger.error.mock.calls expect(payload.message).toBe('staleFromVBase.revalidateError') diff --git a/node/__tests__/withRequestTimings.test.ts b/node/__tests__/withRequestTimings.test.ts index 0d72e414..d7782327 100644 --- a/node/__tests__/withRequestTimings.test.ts +++ b/node/__tests__/withRequestTimings.test.ts @@ -43,7 +43,9 @@ describe('withRequestTimings', () => { }) expect(ctx.vtex.logger.warn).toHaveBeenCalledTimes(1) - expect(ctx.vtex.logger.warn.mock.calls[0][0].orgId).toBe('org1') + const [[successPayload]] = ctx.vtex.logger.warn.mock.calls + + expect(successPayload.orgId).toBe('org1') }) it('always logs a failure and rethrows, regardless of threshold', async () => { @@ -64,9 +66,35 @@ describe('withRequestTimings', () => { ).rejects.toThrow('handler exploded') expect(ctx.vtex.logger.warn).toHaveBeenCalledTimes(1) - const payload = ctx.vtex.logger.warn.mock.calls[0][0] + const [[payload]] = ctx.vtex.logger.warn.mock.calls expect(payload.failed).toBe(true) expect(payload.orgId).toBe('org1') }) + + it('always traces when alwaysTrace is set, even if fast', async () => { + const ctx = makeCtx() + + await withRequestTimings('setProfile.timings', { alwaysTrace: true })( + ctx, + async () => { + const timer = getTimer(ctx) + + if (timer) { + await timer.track('getActiveUserByEmail', Promise.resolve(1)) + timer.meta.extra = { hashChanged: true, orgId: 'org1' } + } + } + ) + + expect(ctx.vtex.logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + hashChanged: true, + message: 'setProfile.timings', + orgId: 'org1', + slowestStep: 'getActiveUserByEmail', + }) + ) + expect(ctx.vtex.logger.warn).not.toHaveBeenCalled() + }) }) diff --git a/node/clients/checkout.ts b/node/clients/checkout.ts index b53e31a9..072e6b2b 100644 --- a/node/clients/checkout.ts +++ b/node/clients/checkout.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-params */ import type { InstanceOptions, IOContext, diff --git a/node/directives/withSender.ts b/node/directives/withSender.ts index 8e68242c..ec74dce2 100644 --- a/node/directives/withSender.ts +++ b/node/directives/withSender.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-params */ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { GraphQLField } from 'graphql' import { defaultFieldResolver } from 'graphql' diff --git a/node/directives/withSession.ts b/node/directives/withSession.ts index 327ac708..92169d0f 100644 --- a/node/directives/withSession.ts +++ b/node/directives/withSession.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable max-params */ import type { GraphQLField } from 'graphql' import { defaultFieldResolver } from 'graphql' import { SchemaDirectiveVisitor } from 'graphql-tools' diff --git a/node/directives/withUserPermissions.ts b/node/directives/withUserPermissions.ts index c4a2957d..96d5a594 100644 --- a/node/directives/withUserPermissions.ts +++ b/node/directives/withUserPermissions.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable max-params */ import type { GraphQLField } from 'graphql' import { defaultFieldResolver } from 'graphql' import { SchemaDirectiveVisitor } from 'graphql-tools' diff --git a/node/index.ts b/node/index.ts index 89c1b26f..398509e2 100644 --- a/node/index.ts +++ b/node/index.ts @@ -81,7 +81,7 @@ export default new Service({ }), setProfile: method({ POST: [ - withRequestTimings('setProfile.timings'), + withRequestTimings('setProfile.timings', { alwaysTrace: true }), resolvers.Routes.setProfile, ], }), diff --git a/node/jest.config.js b/node/jest.config.js index 0bd43aae..f6cd6f1e 100644 --- a/node/jest.config.js +++ b/node/jest.config.js @@ -7,7 +7,8 @@ module.exports = { // package `exports` subpaths that jest 26's resolver (pinned by // TypeScript 3.9 -> ts-jest 26) predates. Tests never exercise the log // exporter, so the whole package is stubbed. - '^@vtex/diagnostics-nodejs(/.*)?$': '/__tests__/stubs/diagnostics.js', + '^@vtex/diagnostics-nodejs(/.*)?$': + '/__tests__/stubs/diagnostics.js', }, // Keep the stub itself from being collected as a test file. testPathIgnorePatterns: ['/node_modules/', '/__tests__/stubs/'], diff --git a/node/middlewares/withRequestTimings.ts b/node/middlewares/withRequestTimings.ts index 10d657bb..9a70f70a 100644 --- a/node/middlewares/withRequestTimings.ts +++ b/node/middlewares/withRequestTimings.ts @@ -3,6 +3,7 @@ import type { Timer } from '../utils/requestTimings' import { attachTimer, createTimer, + emitTimerTrace, logRequestTimings, } from '../utils/requestTimings' @@ -41,9 +42,13 @@ const maybeEmitCacheStats = (ctx: Context) => { * handed to the handler, and emitted from here for both outcomes: * * - success: only when slow or sampled, using the account's configured limits + * (unless `alwaysTrace`, used while diagnosing setProfile) * - failure: always, regardless of those limits */ -export const withRequestTimings = (message: string) => +export const withRequestTimings = ( + message: string, + options: { alwaysTrace?: boolean } = {} +) => // Named rather than an anonymous arrow: service-node reports per-handler // metrics by function name and logs an error for unnamed handlers. async function requestTimings(ctx: Context, next: () => Promise) { @@ -52,22 +57,37 @@ export const withRequestTimings = (message: string) => attachTimer(ctx, timer) maybeEmitCacheStats(ctx) + const extraFields = () => timer.meta.extra ?? {} + try { await next() } catch (error) { - logRequestTimings({ - extra: { ...timer.meta.extra, failed: true }, - logger: ctx.vtex.logger, - message, - slowThresholdMs: 0, - timer, - }) + if (options.alwaysTrace) { + emitTimerTrace(ctx.vtex.logger, message, timer, { + ...extraFields(), + failed: true, + }) + } else { + logRequestTimings({ + extra: { ...extraFields(), failed: true }, + logger: ctx.vtex.logger, + message, + slowThresholdMs: 0, + timer, + }) + } throw error } + if (options.alwaysTrace) { + emitTimerTrace(ctx.vtex.logger, message, timer, extraFields()) + + return + } + logRequestTimings({ - extra: timer.meta.extra, + extra: extraFields(), logger: ctx.vtex.logger, message, sampleRate: timer.meta.sampleRate, diff --git a/node/resolvers/Mutations/Users.ts b/node/resolvers/Mutations/Users.ts index 3613158e..ae843445 100644 --- a/node/resolvers/Mutations/Users.ts +++ b/node/resolvers/Mutations/Users.ts @@ -8,6 +8,8 @@ import { } from '../../utils/constants' import type { ChangeTeamParams } from '../../utils/metrics/changeTeam' import { sendChangeTeamMetric } from '../../utils/metrics/changeTeam' +import { sendObservabilityEvent } from '../../utils/observabilityEvent' +import { createTimer } from '../../utils/requestTimings' import { getAllUsersByEmail, getOrganizationsByEmail, @@ -554,6 +556,8 @@ export const addCostCenterToUser = async ( } } +const B2B_USERS_SEARCH_PAGE_SIZE = 50 + export const setActiveUserByOrganization = async ( _: any, params: any, @@ -564,76 +568,152 @@ export const setActiveUserByOrganization = async ( vtex: { logger, adminUserAuthToken, sessionToken }, } = ctx - let userId = null + const timer = createTimer() + const extra: Record = { + costId: params.costId ?? null, + orgId: params.orgId ?? null, + } - if (adminUserAuthToken) { - userId = params.userId - } else { - const sessionData = await session - .getSession(sessionToken as string, ['*']) - .then((currentSession: any) => { - return currentSession.sessionData - }) - .catch((error: any) => { - logger.error({ - error: describeClientError(error), - message: 'orders-getSession-error', - }) + try { + let userId = null + + if (adminUserAuthToken) { + userId = params.userId + } else { + const sessionData = await timer.track( + 'getSession', + session + .getSession(sessionToken as string, ['*']) + .then((currentSession: any) => { + return currentSession.sessionData + }) + .catch((error: any) => { + logger.error({ + error: describeClientError(error), + message: 'orders-getSession-error', + }) - return null - }) + return null + }) + ) - const currentUserEmail = - sessionData?.namespaces?.profile?.email?.value ?? params.email + const currentUserEmail = + sessionData?.namespaces?.profile?.email?.value ?? params.email - const userByEmail = (await getUserByEmailOrgIdAndCostId( - masterdata, - { - email: currentUserEmail, - costId: params.costId, - orgId: params.orgId, - }, - ctx - )) as any + const userByEmail = (await timer.track( + 'getUserByEmailOrgIdAndCostId', + getUserByEmailOrgIdAndCostId( + masterdata, + { + costId: params.costId, + email: currentUserEmail, + orgId: params.orgId, + }, + ctx + ) + )) as any - userId = userByEmail - ? userByEmail.id - : sessionData?.namespaces?.['storefront-permissions']?.userId?.value - } + userId = userByEmail + ? userByEmail.id + : sessionData?.namespaces?.['storefront-permissions']?.userId?.value + } - const user = await getUser({ masterdata, params: { userId } }) + const user = await timer.track( + 'getUser', + getUser({ masterdata, params: { userId } }) + ) - if (!user) { - throw new Error('User not found') - } + if (!user) { + throw new Error('User not found') + } - await updateUserFields({ - fields: { ...user, active: true }, - id: userId, - masterdata, - }) + extra.costId = user.costId + extra.orgId = user.orgId + extra.userId = user.id - const users = await getAllUsersByEmail(_, { email: user.email }, ctx) + await timer.track( + 'activate', + updateUserFields({ + fields: { ...user, active: true }, + id: userId, + masterdata, + }) + ) - try { - const promises = users.map(async (userSecondary: any) => { - if (userSecondary.id !== user.id) { - await updateUserFields({ - fields: { - ...userSecondary, - active: false, - }, - id: userSecondary.id, - masterdata, - }) - } - }) + const users = await timer.track( + 'listUsers', + getAllUsersByEmail(_, { email: user.email }, ctx) + ) - await Promise.all(promises) - } catch (error) { - logger.error({ - error: describeClientError(error), - message: 'setActiveUserById.error', + const listed = Array.isArray(users) ? users : [] + const deactivateTargets = listed.filter( + (userSecondary: any) => userSecondary.id !== user.id + ) + + extra.currentlyActiveCount = listed.filter( + (userSecondary: any) => userSecondary.active + ).length + extra.deactivateWrites = deactivateTargets.length + extra.listedCount = listed.length + extra.searchPagesEstimate = + listed.length > 0 + ? Math.ceil(listed.length / B2B_USERS_SEARCH_PAGE_SIZE) + : 1 + + try { + await timer.track( + 'deactivateOthers', + Promise.all( + deactivateTargets.map((userSecondary: any) => + updateUserFields({ + fields: { + ...userSecondary, + active: false, + }, + id: userSecondary.id, + masterdata, + }) + ) + ) + ) + } catch (error) { + logger.error({ + error: describeClientError(error), + message: 'setActiveUserById.error', + }) + } + } finally { + const totalMs = timer.totalMs() + const steps = Object.keys(timer.timings) + const slowestStep = steps.reduce( + (slowest, step) => + timer.timings[step] > (timer.timings[slowest] ?? -1) ? step : slowest, + steps[0] ?? '' + ) + + // Org switch is rare enough to log every call. No email: the listedCount + // vs deactivateWrites vs currentlyActiveCount is what confirms the MD + // fan-out (full scan + N-1 entire-document writes). + logger.info({ + costId: extra.costId ?? null, + currentlyActiveCount: extra.currentlyActiveCount ?? 0, + deactivateWrites: extra.deactivateWrites ?? 0, + listedCount: extra.listedCount ?? 0, + message: 'setActiveUserByOrganization.timings', + orgId: extra.orgId ?? null, + searchPagesEstimate: extra.searchPagesEstimate ?? 0, + slowestStep, + slowestStepMs: timer.timings[slowestStep] ?? 0, + timings: timer.timings, + totalMs, + userId: extra.userId ?? null, + }) + + sendObservabilityEvent(ctx, 'set-active-user-by-organization', { + currentlyActiveCount: Number(extra.currentlyActiveCount ?? 0), + deactivateWrites: Number(extra.deactivateWrites ?? 0), + listedCount: Number(extra.listedCount ?? 0), + totalMs, }) } } diff --git a/node/resolvers/Queries/Users.ts b/node/resolvers/Queries/Users.ts index 511cccd5..68c158ae 100644 --- a/node/resolvers/Queries/Users.ts +++ b/node/resolvers/Queries/Users.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { removeVersionFromAppId } from '@vtex/api' +import { getCachedActiveUserForPermissions } from '../../services/activeUserCache' import { getCachedAppSettings } from '../../services/appSettingsCache' import type { GetOrganizationsPaginatedByEmailResponse } from '../../typings/custom' import { currentSchema } from '../../utils' @@ -10,6 +11,8 @@ import { CUSTOMER_SCHEMA_NAME, } from '../../utils/constants' import GraphQLError from '../../utils/GraphQLError' +import type { Timer } from '../../utils/requestTimings' +import { createTimer, emitTimerTrace } from '../../utils/requestTimings' import { getRole } from './Roles' const config: any = currentSchema('b2b_users') @@ -19,6 +22,19 @@ const PAGINATION = { pageSize: 50, } +const USER_SEARCH_FIELDS = [ + 'id', + 'roleId', + 'clId', + 'email', + 'name', + 'orgId', + 'costId', + 'userId', + 'canImpersonate', + 'active', +] + // This function checks if given email is an user part of a buyer org. export const isUserPartOfBuyerOrg = async (email: string, ctx: Context) => { const { @@ -77,50 +93,49 @@ export const getAllUsers = async ({ where?: string }) => { try { - const initialResp = await masterdata.searchDocumentsWithPaginationInfo({ + // Fetch page 1 with the fields callers need. When the result fits in one + // page (the active-user path is 0..1 records; most emails hold a handful) + // this is the only Master Data round-trip. The previous count probe + // (`fields: ['id']`) then re-fetched page 1 in full, doubling the cost of + // every small search. + const firstPage = await masterdata.searchDocumentsWithPaginationInfo({ dataEntity: config.name, - fields: ['id'], + fields: USER_SEARCH_FIELDS, pagination: { page: 1, pageSize: PAGINATION.pageSize }, schema: config.version, + sort: 'id asc', ...(where ? { where } : {}), }) - const totalItems = initialResp.pagination.total - const totalPages = Math.ceil(totalItems / PAGINATION.pageSize) + const users: any[] = [...(firstPage.data ?? [])] + const totalPages = Math.ceil( + (firstPage.pagination?.total ?? 0) / PAGINATION.pageSize + ) + + if (totalPages <= 1) { + return users + } - const requests = Array.from( - { length: totalPages }, + const remaining = Array.from( + { length: totalPages - 1 }, (_, i) => async () => masterdata.searchDocumentsWithPaginationInfo({ dataEntity: config.name, - fields: [ - 'id', - 'roleId', - 'clId', - 'email', - 'name', - 'orgId', - 'costId', - 'userId', - 'canImpersonate', - 'active', - ], - pagination: { page: i + 1, pageSize: PAGINATION.pageSize }, + fields: USER_SEARCH_FIELDS, + pagination: { page: i + 2, pageSize: PAGINATION.pageSize }, schema: config.version, sort: 'id asc', ...(where ? { where } : {}), }) ) - const responses = await processChunks(requests) + const responses = await processChunks(remaining) - const users = responses.reduce((acc: any[], resp: { data: any }) => { + return responses.reduce((acc: any[], resp: { data: any }) => { acc.push(...resp.data) return acc - }, []) - - return users + }, users) } catch (error) { logger.error({ error: describeClientError(error), @@ -187,7 +202,7 @@ export const getActiveUserByEmail = async ( }) } - let userFound = activeUsers[0] + let [userFound] = activeUsers // No explicit selection, but the session already carries an organization // from a previous transform: keep it. Without this the resolution below is @@ -302,9 +317,44 @@ export const getActiveUserByEmail = async ( * @param ctx */ export const getUserByEmail = async (_: any, params: any, ctx: Context) => { - const user = await getActiveUserByEmail(_, params, ctx) + const email = params?.email + + if (!email) { + const unresolved = await getActiveUserByEmail(_, params, ctx) + + return [unresolved] + } + + // Same 5-minute memory cache as the REST checkPermissions route: GraphQL + // checkUserPermission is called per request by sibling B2B apps (and often + // several times in the same navigation) with only email, so there is no + // cost-center key to invalidate on an organization switch. + const cachedUser = await getCachedActiveUserForPermissions( + ctx, + email, + async () => { + const activeUser: any = await getActiveUserByEmail(_, { email }, ctx) + + if (activeUser?.status === 'error') { + throw activeUser.message + } - return [user] + if (!activeUser?.id) { + const notFound: any = new Error('getUserByEmail.userNotFound') + + notFound.userNotFound = true + throw notFound + } + + return activeUser + } + ).catch((error) => + error?.userNotFound + ? { email: '', name: '' } + : { message: error, status: 'error' } + ) + + return [cachedUser] } export const getUserById = async (_: any, params: any, ctx: Context) => { @@ -665,16 +715,23 @@ const getRoleAndPermissionsByEmail = async ({ module, skipError = false, ctx, + stepPrefix = '', + timer, }: { email: string module: string skipError: boolean ctx: Context + stepPrefix?: string + timer?: Timer }) => { const { vtex: { logger }, } = ctx + const track = (step: string, promise: Promise): Promise => + timer ? timer.track(`${stepPrefix}${step}`, promise) : promise + const defaultResponse = { permissions: [], role: { @@ -688,7 +745,10 @@ const getRoleAndPermissionsByEmail = async ({ return defaultResponse } - const userData: any = await getUserByEmail(null, { email }, ctx) + const userData: any = await track( + 'getUserByEmail', + getUserByEmail(null, { email }, ctx) + ) if (!userData.length && !skipError) { logger.warn({ @@ -702,7 +762,10 @@ const getRoleAndPermissionsByEmail = async ({ return defaultResponse } - const userRole: any = await getRole(null, { id: userData[0].roleId }, ctx) + const userRole: any = await track( + 'getRole', + getRole(null, { id: userData[0].roleId }, ctx) + ) if (!userRole && !skipError) { logger.warn({ @@ -740,117 +803,164 @@ export const checkUserPermission = async ( vtex: { logger }, } = ctx - const { sessionData, sender }: any = ctx.vtex + const timer = createTimer() + const extra: Record = {} - const skipError = params?.skipError ?? false + try { + const { sessionData, sender }: any = ctx.vtex - if (!sessionData?.namespaces && !skipError) { - logger.warn({ - message: `checkUserPermission-userNotAuthenticated`, - }) - throw new GraphQLError( - 'User not authenticated, make sure the query is private', - { - logLevel: 'warn', - } - ) - } + const skipError = params?.skipError ?? false - if (!sender && !skipError) { - logger.warn({ - message: `checkUserPermission-senderNotFound`, - }) - throw new GraphQLError( - 'Sender not available, make sure the query is private', - { - logLevel: 'warn', - } - ) - } + if (!sessionData?.namespaces && !skipError) { + extra.reason = 'userNotAuthenticated' + logger.warn({ + message: `checkUserPermission-userNotAuthenticated`, + }) + throw new GraphQLError( + 'User not authenticated, make sure the query is private', + { + logLevel: 'warn', + } + ) + } - const authEmail = - sessionData?.namespaces?.authentication?.storeUserEmail?.value + if (!sender && !skipError) { + extra.reason = 'senderNotFound' + logger.warn({ + message: `checkUserPermission-senderNotFound`, + }) + throw new GraphQLError( + 'Sender not available, make sure the query is private', + { + logLevel: 'warn', + } + ) + } - const profileEmail = sessionData?.namespaces?.profile?.email?.value + const authEmail = + sessionData?.namespaces?.authentication?.storeUserEmail?.value - const defaultResponse = { - permissions: [], - role: { - id: '', - name: '', - slug: '', - }, - } + const profileEmail = sessionData?.namespaces?.profile?.email?.value - if (!sender) { - return defaultResponse - } + const defaultResponse = { + permissions: [], + role: { + id: '', + name: '', + slug: '', + }, + } - const module = removeVersionFromAppId(sender) + if (!sender) { + extra.reason = 'noSender' - // Both impersonation flows (vtex.telemarketing and the Organizations app) - // switch the profile namespace to the impersonated user while - // authentication.storeUserEmail keeps holding the acting user, so a - // divergence between the two is what identifies an impersonation session. - const isImpersonating = Boolean(profileEmail) && authEmail !== profileEmail + return defaultResponse + } - if (!isImpersonating) { - return getRoleAndPermissionsByEmail({ - ctx, - email: authEmail, - module, - skipError: true, - }) - } + const module = removeVersionFromAppId(sender) - // Only impersonation sessions need the setting, so regular sessions never - // pay for reading it (cached for 5 minutes when they do). - const appSettings = await getCachedAppSettings(ctx).catch((error) => { - logger.warn({ error: describeClientError(error), message: 'checkUserPermission-getAppSettingsError' }) + extra.module = module - return {} as Record - }) + // Both impersonation flows (vtex.telemarketing and the Organizations app) + // switch the profile namespace to the impersonated user while + // authentication.storeUserEmail keeps holding the acting user, so a + // divergence between the two is what identifies an impersonation session. + const isImpersonating = Boolean(profileEmail) && authEmail !== profileEmail - // Strict mode: scope the evaluation to the impersonated profile so the - // acting user's elevated permissions never reach the storefront. - if ((appSettings as any)?.strictImpersonationPermissions) { - return getRoleAndPermissionsByEmail({ - ctx, - email: profileEmail, - module, - skipError: true, - }) - } + extra.impersonating = isImpersonating - // Aggregated mode (default): keep the legacy union, which flows relying on - // the acting user's rights while impersonating depend on - for example a - // sales representative completing checkout for a buyer role that has no - // can-checkout permission, or an approver retaining approval power. - const [authPermissions, profilePermissions] = await Promise.all([ - getRoleAndPermissionsByEmail({ - ctx, - email: authEmail, - module, - skipError: true, - }), - getRoleAndPermissionsByEmail({ - ctx, - email: profileEmail, - module, - skipError: true, - }), - ]) + if (!isImpersonating) { + const sessionPermissions = await getRoleAndPermissionsByEmail({ + ctx, + email: authEmail, + module, + skipError: true, + timer, + }) - return { - permissions: [ - ...new Set([ - ...authPermissions.permissions, - ...profilePermissions.permissions, - ]), - ], - role: authPermissions.role.id - ? authPermissions.role - : profilePermissions.role, + extra.permissionCount = sessionPermissions.permissions.length + extra.roleId = sessionPermissions.role.id || null + + return sessionPermissions + } + + // Only impersonation sessions need the setting, so regular sessions never + // pay for reading it (cached for 5 minutes when they do). + const appSettings = await timer.track( + 'getAppSettings', + getCachedAppSettings(ctx).catch((error) => { + logger.warn({ + error: describeClientError(error), + message: 'checkUserPermission-getAppSettingsError', + }) + + return {} as Record + }) + ) + + // Strict mode: scope the evaluation to the impersonated profile so the + // acting user's elevated permissions never reach the storefront. + if ((appSettings as any)?.strictImpersonationPermissions) { + extra.strictImpersonation = true + + const impersonatedPermissions = await getRoleAndPermissionsByEmail({ + ctx, + email: profileEmail, + module, + skipError: true, + stepPrefix: 'profile.', + timer, + }) + + extra.permissionCount = impersonatedPermissions.permissions.length + extra.roleId = impersonatedPermissions.role.id || null + + return impersonatedPermissions + } + + extra.strictImpersonation = false + + // Aggregated mode (default): keep the legacy union, which flows relying on + // the acting user's rights while impersonating depend on - for example a + // sales representative completing checkout for a buyer role that has no + // can-checkout permission, or an approver retaining approval power. + const [authPermissions, profilePermissions] = await Promise.all([ + getRoleAndPermissionsByEmail({ + ctx, + email: authEmail, + module, + skipError: true, + stepPrefix: 'auth.', + timer, + }), + getRoleAndPermissionsByEmail({ + ctx, + email: profileEmail, + module, + skipError: true, + stepPrefix: 'profile.', + timer, + }), + ]) + + const aggregatedPermissions = { + permissions: [ + ...new Set([ + ...authPermissions.permissions, + ...profilePermissions.permissions, + ]), + ], + role: authPermissions.role.id + ? authPermissions.role + : profilePermissions.role, + } + + extra.permissionCount = aggregatedPermissions.permissions.length + extra.roleId = aggregatedPermissions.role.id || null + + return aggregatedPermissions + } finally { + emitTimerTrace(logger, 'checkUserPermission.timings', timer, extra) } } @@ -962,25 +1072,40 @@ export const getOrganizationsByEmail = async ( vtex: { logger }, } = ctx - const { email } = params + const timer = createTimer() + const extra: Record = {} try { - return (await getAllUsersByEmail(null, { email }, ctx)).map( - (user: any) => ({ - clId: user.clId, - costId: user.costId, - id: user.id, - orgId: user.orgId, - roleId: user.roleId, - }) + const { email } = params + + const users = await timer.track( + 'listUsers', + getAllUsersByEmail(null, { email }, ctx) ) + + const listedCount = Array.isArray(users) ? users.length : 0 + + extra.listedCount = listedCount + extra.searchPagesEstimate = + listedCount > 0 ? Math.ceil(listedCount / PAGINATION.pageSize) : 1 + + return users.map((user: any) => ({ + clId: user.clId, + costId: user.costId, + id: user.id, + orgId: user.orgId, + roleId: user.roleId, + })) } catch (error) { + extra.error = true logger.error({ error: describeClientError(error), message: `getOrganizationsByEmail-error`, }) return { status: 'error', message: error } + } finally { + emitTimerTrace(logger, 'getOrganizationsByEmail.timings', timer, extra) } } diff --git a/node/services/activeUserCache.ts b/node/services/activeUserCache.ts index 24bb8a80..839dea92 100644 --- a/node/services/activeUserCache.ts +++ b/node/services/activeUserCache.ts @@ -70,11 +70,12 @@ export const getCachedActiveUserByEmail = async ( ) /** - * Variant for permission checks (checkPermissions route). Those requests carry - * only app + email, so there is no session cost center to key on and an - * organization switch cannot invalidate by key. Memory-only with a short TTL, - * so stale permissions are bounded to that window and never extended by a - * cross-pod layer. + * Variant for permission checks (REST checkPermissions and GraphQL + * checkUserPermission / getUserByEmail). Those requests carry only app + email, + * so there is no session cost center to key on and an organization switch + * cannot invalidate by key. Memory-only with a 5-minute TTL, so stale + * permissions are bounded to that window and never extended by a cross-pod + * layer. REST and GraphQL share the cache. */ const cachedPermissionsUser = createCachedResource( 'active-user-permissions', diff --git a/node/services/appSettingsCache.ts b/node/services/appSettingsCache.ts index 7f9ff2cc..78ef6fb5 100644 --- a/node/services/appSettingsCache.ts +++ b/node/services/appSettingsCache.ts @@ -23,7 +23,9 @@ export const getCachedAppSettings = async ( const appId = process.env.VTEX_APP_ID ?? '' const cached = await cachedAppSettings(ctx, appId, () => - ctx.clients.apps.getAppSettings(appId).then((res) => (res ?? {}) as AppSettings) + ctx.clients.apps + .getAppSettings(appId) + .then((res) => (res ?? {}) as AppSettings) ) return cached != null && typeof cached === 'object' ? cached : {} diff --git a/node/utils/clientError.ts b/node/utils/clientError.ts index b577255a..00c3a3a3 100644 --- a/node/utils/clientError.ts +++ b/node/utils/clientError.ts @@ -52,8 +52,7 @@ export const describeClientError = (error: any) => { code: error?.code ?? null, message: redact(error?.message), method: error?.config?.method ?? null, - operationId: - headers['x-vtex-operation-id'] ?? body?.operationId ?? null, + operationId: headers['x-vtex-operation-id'] ?? body?.operationId ?? null, path: stripQuery(error?.config?.url), requestId: headers['x-request-id'] ?? null, // Redacted like `message`: a stack's first line repeats the error message, diff --git a/node/utils/constants.ts b/node/utils/constants.ts index a3f5cb0f..acadd26a 100644 --- a/node/utils/constants.ts +++ b/node/utils/constants.ts @@ -86,10 +86,11 @@ export const ACTIVE_USER_CACHE_TTL_IN_MINUTES = 5 /** * Variant used by permission checks, which have no session cost center to key - * on, so an organization switch cannot invalidate by key. Kept memory-only and - * short so stale permissions are bounded to this window. + * on, so an organization switch cannot invalidate by key. Memory-only, matching + * the in-memory half of the session active-user cache. No VBase layer, so this + * window is the upper bound on stale permissions after an organization switch. */ -export const PERMISSIONS_USER_CACHE_TTL_IN_MS = 60 * 1000 +export const PERMISSIONS_USER_CACHE_TTL_IN_MS = 5 * 60 * 1000 /** * The region lookup is a deterministic function of country, postal code, sales diff --git a/node/utils/cookie.ts b/node/utils/cookie.ts index b697d953..7238b337 100644 --- a/node/utils/cookie.ts +++ b/node/utils/cookie.ts @@ -36,7 +36,7 @@ const checkoutCookieFormat = (orderFormId: string) => const getOrderFormIdFromCookie = (cookies: Context['cookies']) => { const cookie = cookies.get(CHECKOUT_COOKIE) - return cookie && cookie.split('=')[1] + return cookie?.split('=')[1] } export { diff --git a/node/utils/requestTimings.ts b/node/utils/requestTimings.ts index dc9f19e4..ae0adc2c 100644 --- a/node/utils/requestTimings.ts +++ b/node/utils/requestTimings.ts @@ -54,13 +54,14 @@ export const createTimer = (): Timer => { * its final statement. Keyed weakly by the request context, so entries disappear * with the request. */ -const timers = new WeakMap() +const timers = new WeakMap, Timer>() -export const attachTimer = (ctx: object, timer: Timer) => { +export const attachTimer = (ctx: Record, timer: Timer) => { timers.set(ctx, timer) } -export const getTimer = (ctx: object): Timer | undefined => timers.get(ctx) +export const getTimer = (ctx: Record): Timer | undefined => + timers.get(ctx) export interface LogRequestTimingsArgs { extra?: Record @@ -116,3 +117,33 @@ export const logRequestTimings = ({ logger.info(payload) } } + +/** + * Always-on timings for GraphQL hops we are investigating (not sampled like + * the default setProfile path). + */ +export const emitTimerTrace = ( + logger: Logger, + message: string, + timer: Timer, + extra: Record = {} +) => { + const totalMs = timer.totalMs() + const steps = Object.keys(timer.timings) + const slowestStep = steps.reduce( + (slowest, step) => + timer.timings[step] > (timer.timings[slowest] ?? -1) ? step : slowest, + steps[0] ?? '' + ) + + const payload = { + message, + slowestStep, + slowestStepMs: timer.timings[slowestStep] ?? 0, + timings: timer.timings, + totalMs, + ...extra, + } + + logger.info(payload) +} diff --git a/node/utils/staleFromVBaseWhileRevalidate.ts b/node/utils/staleFromVBaseWhileRevalidate.ts index 553bd94e..439ed441 100644 --- a/node/utils/staleFromVBaseWhileRevalidate.ts +++ b/node/utils/staleFromVBaseWhileRevalidate.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-params */ import { createHash } from 'crypto' import type { VBase } from '@vtex/api' From 9134300234236572926e09a3db8bb21f6a92fce1 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Wed, 26 Aug 2026 16:18:13 -0400 Subject: [PATCH 19/19] Release v3.8.1 --- CHANGELOG.md | 2 ++ manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b9db16c..0a4ea686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [3.8.1] - 2026-08-26 + ### Changed - GraphQL `checkUserPermission` / `getUserByEmail` reuse the memory `active-user-permissions` cache already used by the REST `checkPermissions` route (TTL 5 minutes), so sibling B2B apps (and repeated hops in the same navigation) no longer hit Master Data on every call. diff --git a/manifest.json b/manifest.json index 235570c9..2059ebed 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "storefront-permissions", "vendor": "vtex", - "version": "3.8.0", + "version": "3.8.1", "title": "Storefront Permissions", "description": "Manage User's permissions on apps that relates to this app", "mustUpdateAt": "2022-08-28",