npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@revenexx/consent-manager-nuxt

v0.1.1

Published

Server-rendered cookie consent for Nuxt without a third-party script: an SSR consent banner, the rvx_consent cookie, Google Consent Mode v2, <ConsentGate> content blocking and a consent trigger for @nuxt/scripts, driven by the revenexx Consent Manager.

Downloads

301

Readme

@revenexx/consent-manager-nuxt

A server-rendered consent banner for Nuxt that loads no third-party script. The first HTML response already contains the banner, the visitor's earlier decision and the Google Consent Mode v2 default. Content embeds, analytics tags and @nuxt/scripts loaders stay blocked until the visitor allows them. The policy (purposes, vendors, texts) comes from the revenexx Consent Manager app, and every decision is recorded there.

npm version npm downloads License: MIT


Why

  • 🖥️ Rendered by your own server. No consent SaaS script is loaded before the decision. The Nuxt server reads the rvx_consent cookie and the published policy, so the first response already contains the banner if one is needed, and nothing the visitor has not allowed. You get no layout shift and no flash of tracking.
  • ⚖️ "Reject all" sits next to "Accept all". Both buttons are on the first layer, in every layout (box, bar, modal), and share one CSS class, so you cannot style one of them down. This is deliberately not configurable.
  • 📊 Google Consent Mode v2 (basic). The default command (everything denied except security_storage) is the first dataLayer entry in <head>, and each decision pushes an update.
  • 🧱 <ConsentGate> for embeds. YouTube, maps and similar embeds are not rendered at all until their vendor is allowed. A local placeholder takes their place, with Load once for a single vendor.
  • 🔌 One seam for loaders. $consentProvider plugs straight into @nuxt/scripts triggers, and @revenexx/tag-manager-nuxt uses the same seam.
  • 🧾 Decisions are recorded without personal data. Each decision is stored against the exact policy version that was shown. The record carries no IP address, no full user agent and no query string.
  • 🛡️ Fails closed. If there is no policy, no credentials or the gateway is down, no banner is shown and nothing optional loads.
  • 🎨 Themeable or headless. CSS variables, a slot for every surface, or useConsents() to build your own UI.

The consent configuration lives in the Consent Manager app (revenexx Experience Studio), not in nuxt.config: purposes and their legal bases, vendors, cookies and banner texts per locale. This module renders and enforces what that app publishes.

Contents

Installation

npm i @revenexx/consent-manager-nuxt @nuxt/scripts     # or pnpm add / yarn add

@nuxt/scripts is a peer dependency. The module installs it for you if it is missing from modules, but listing it yourself keeps the order explicit.

Quick start

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/scripts', '@revenexx/consent-manager-nuxt'],
  consentManager: {
    // every option has a default, see Configuration
  },
})
<!-- app.vue (or your layout) -->
<template>
  <NuxtPage />

  <!-- First layer and settings layer. Renders only when a decision is owed or the visitor reopens it. -->
  <ConsentBanner />

  <footer>
    <!-- Reopens the settings layer with the current decisions preset. -->
    <ConsentPreferencesLink />
  </footer>
</template>

Gate an embed:

<ConsentGate vendor="youtube" thumbnail="/media/video-preview.jpg">
  <iframe src="https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ" title="Video" allowfullscreen />
</ConsentGate>

Load a @nuxt/scripts script only once the visitor allows it:

const { $consentProvider } = useNuxtApp()

useScriptGoogleAnalytics({
  id: 'G-XXXXXXX',
  scriptOptions: { trigger: $consentProvider.trigger('google-analytics', 'statistics') },
})

google-analytics and statistics are codes from your published policy: a vendor code and one of that vendor's purpose codes.

For local development, point the module at a tenant (see Credentials):

# .env (never commit it)
NUXT_CONSENT_MANAGER_TENANT=<your-tenant>
NUXT_CONSENT_MANAGER_API_KEY=<your-gateway-api-key>

Without credentials the module still builds and renders. It shows no banner and allows nothing optional.

How it works

request ──► Nuxt server (plugin, SSR)
              │ reads cookie rvx_consent
              │ GET /_consent-manager/policy ──► api.revenexx.com
              │                                  /v1/consent-manager/delivery/policy
              │                                  (cached 60 s per tenant + market)
              ▼
            HTML: <head> Consent Mode default (+ update for a returning visitor)
                  <ConsentBanner> if a decision is owed
                  <ConsentGate> placeholders for vendors not allowed
              │
browser ──► visitor decides ──► cookie rvx_consent written
                              ├► gtag('consent','update', …)
                              ├► consent-manager:change hook + revenexx:consent DOM event
                              ├► @nuxt/scripts triggers resolve (scripts load, no reload)
                              └► POST /_consent-manager/record ──► /v1/consent-manager/records

When the banner shows. It shows when there is no valid rvx_consent cookie, when the cookie predates the latest material policy version, or when it is older than the policy's consent_lifetime_days (365 if unset). A malformed cookie counts as no cookie. When the policy is being previewed, the banner shows on every page load and closes after a decision on that page. The decision is not recorded.

What "allowed" means. allows(vendor, purpose) is false in an editor context, without a policy, for a vendor not in the policy and for a purpose that vendor does not serve. Otherwise it follows the effective legal basis (vendor.legal_basis_override ?? purpose.legal_basis):

| Legal basis | Allowed when | | --- | --- | | necessary | always | | legitimate_interest | the visitor has not objected (to the purpose or to the vendor) | | consent | the visitor granted the purpose, or granted the vendor via Load once |

What each button writes.

| Action | Effect | | --- | --- | | Accept all | grants every non-necessary purpose | | Reject all | denies every consent purpose and objects to every legitimate-interest purpose and every vendor whose override is legitimate interest | | Save (settings) | stores the purposes as toggled. A consent purpose left untouched is denied. | | Load once (gate) | grants one vendor and keeps everything else | | Withdraw (useConsents().withdraw()) | like Reject all, plus a new consent id |

Revoking. A script cannot be unloaded. When a decision takes away a vendor that was allowed before, the module deletes that vendor's declared first-party cookies and local/session storage keys and then reloads the page (unless reloadOnRevoke: false).

Editor context. On theme-editor hosts and under the editor/preview paths, the module does not fetch the policy, renders no banner and no preferences link, and allows() is false for everything. Merchants editing a page never trigger a vendor.

The full normative description (cookie format, gating formula, failure modes) is in docs/contract.md.

Configuration

All options go under consentManager in nuxt.config.ts.

| Option | Type | Default | Description | | --- | --- | --- | --- | | exemptOnEditorHosts | boolean | true | On editor hosts and editor paths, show no banner and load nothing optional. Set false to treat them like any other page. | | editorHosts | string[] | ['*.theme.rvnxx.site'] | Hosts that count as editor context. *.example.com matches any subdomain. On the server, x-forwarded-host is used when present. | | editorPaths | string[] | ['/admin', '/preview'] | Path prefixes that count as editor context (/admin matches /admin and /admin/**). | | cookieName | string | 'rvx_consent' | Name of the visitor-state cookie. The name is part of the shared contract with other revenexx packages, so change it only for tests. | | dataLayerName | string | 'dataLayer' | The global the Consent Mode commands are pushed to (window[dataLayerName]). | | marketCookie | string | 'cover-market' | Cookie that holds the storefront's market code. It is forwarded as x-revenexx-market when the request has no such header. Server-only. | | previewQuery | string | 'rvx_consent_preview' | Query parameter that carries a policy preview token. ?rvx_consent_preview=<token> renders the unpublished draft. The banner shows on every page load and closes after a decision; decisions made in a preview are not recorded. | | reloadOnRevoke | boolean | true | Reload after a decision revoked a vendor that was allowed before, so its scripts stop running. | | apiUrl | string | '' → 'https://api.revenexx.com' | Gateway base URL, with or without a trailing /v1. Server-only. | | tenant | string | '' | Gateway tenant for local development. A deployed revenexx Site uses the brokered context instead. Server-only. Prefer the environment variable: a value written here is part of the build. | | apiKey | string | '' | Gateway API key for local development. Server-only, never sent to the browser. Prefer the environment variable: a value written here is part of the server build. |

The layout (box / bar / modal), the consent lifetime, the exempt paths, whether the cookie table is shown and whether Consent Mode is on are policy settings in the Consent Manager app, not module options.

Credentials and runtime config

The two server routes call the revenexx gateway. They work out who they are calling as per request, in this order:

  1. Brokered context (deployed revenexx Sites). The platform injects x-revenexx-tenant and a signed x-revenexx-context token into each request. The module forwards the token as Authorization: Bearer …. Nothing needs to be configured. If a request carries the tenant header but no token, a configured API key is used only if it belongs to that same tenant. A key is never sent on behalf of another tenant.
  2. Tenant + API key (everywhere else). tenant and apiKey from runtime config are sent as x-revenexx-tenant + x-revenexx-api-key.

If neither is available, the policy route answers { policy: null, reason: 'not_configured' } and the module fails closed.

The options map to runtime config, so you can set them with environment variables at runtime:

| Environment variable | Runtime config key | Side | | --- | --- | --- | | NUXT_CONSENT_MANAGER_API_URL | consentManager.apiUrl | server | | NUXT_CONSENT_MANAGER_TENANT | consentManager.tenant | server | | NUXT_CONSENT_MANAGER_API_KEY | consentManager.apiKey | server | | NUXT_CONSENT_MANAGER_MARKET_COOKIE | consentManager.marketCookie | server | | NUXT_PUBLIC_CONSENT_MANAGER_COOKIE_NAME, …_DATA_LAYER_NAME, …_EXEMPT_ON_EDITOR_HOSTS, …_EDITOR_HOSTS, …_EDITOR_PATHS, …_PREVIEW_QUERY, …_RELOAD_ON_REVOKE | public.consentManager.* | public |

Fallback. When a NUXT_CONSENT_MANAGER_* value is empty, the server routes fall back to the shop-wide NUXT_REVENEXX_API_URL, NUXT_REVENEXX_TENANT and NUXT_REVENEXX_API_KEY, then to https://api.revenexx.com for the URL.

Credentials are read at runtime only. Since 0.1.1 the module reads no environment variable while nuxt build runs, so a key present on the build machine never ends up in .output. (0.1.0 copied NUXT_REVENEXX_* into the server build as defaults — rebuild with 0.1.1 and rotate the key if you built with it set.) Writing apiKey or tenant into nuxt.config still bakes them into the build, so keep them in the environment.

The market is the incoming x-revenexx-market header if there is one. Otherwise it is the value of the marketCookie cookie, if it is a plain code of up to 32 characters (letters, digits, _, -). Otherwise no market is sent.

Components

All three components are registered globally. They read the state the plugin created, so they must be used inside the Nuxt app that installs the module.

<ConsentBanner>

The first layer and the settings layer. It renders only while a decision is owed or after the visitor reopened it. Place it once, for example in app.vue.

| Prop | Type | Default | Description | | --- | --- | --- | --- | | layout | 'box' \| 'bar' \| 'modal' | policy's banner_layout, else 'box' | Force a layout. On a path listed in the policy's banner_exempt_paths (for example the privacy page) the layout is always bar, so the page underneath stays readable. | | path | string | the current route's path | Override the path used for the exempt-path check. Not needed normally: the default is the router's current path on the server and in the browser alike, so the exempt layout renders during SSR and hydrates without a mismatch. |

| Slot | Slot props | Replaces | | --- | --- | --- | | first-layer | the banner API | the whole first layer (title, text, links, buttons) | | title | { text } | the first-layer heading text | | body | { text } | the first-layer body | | actions | the banner API | the three first-layer buttons | | preferences | the banner API | the whole settings layer | | purpose | { purpose, active, toggle } | one purpose row in the settings layer | | vendor | { vendor } | one vendor entry under a purpose |

Banner API (slot props of first-layer, actions, preferences):

| Prop | Type | Description | | --- | --- | --- | | texts | BannerTexts \| undefined | The banner texts of the current locale (title, body, accept_all, reject_all, settings, save, privacy_url, imprint_url, …). | | layout | 'box' \| 'bar' \| 'modal' | The effective layout. | | acceptAll / rejectAll | () => Promise<void> | Decide. | | showPreferences | () => void | Switch to the settings layer, preset with the current decisions. | | save | () => Promise<void> | Save the toggled choices. | | close | () => void | Close the banner (only closes once a decision exists). | | purposes / vendors | LocalizedPurpose[] / LocalizedVendor[] | The localized policy content. | | choices | { purposes: Record<string, Decision> } | The settings-layer toggles (reactive). | | toggle | (code: string, on: boolean) => void | Toggle one purpose. Switching off a legitimate-interest purpose records an objection. |

If you replace actions, keep Reject all next to Accept all. Accessibility: the banner is role="dialog", can be operated by keyboard and has visible focus. It is named by its heading through aria-labelledby; when a first-layer or preferences slot replaces that heading, the dialog carries the layer's title (title / preferences_title) as aria-label instead. Escape closes it once a decision exists. Only the modal layout traps focus. Focus returns to where it was after the decision. Styling and CSS variables: docs/theming.md.

<ConsentGate>

Blocks content until its vendor is allowed. Until then the default slot is not rendered at all: it is not merely hidden, and no request reaches the vendor. A placeholder takes its place.

| Prop | Type | Default | Description | | --- | --- | --- | --- | | vendor | string | required | Vendor code from the policy. | | purpose | string | none | Check this purpose only. Without it, the content shows as soon as any of the vendor's purposes is allowed. | | thumbnail | string | none | A preview image for the placeholder. Self-host it. A vendor's own thumbnail URL would make the exact request the gate exists to prevent. | | thumbnailAlt | string | '' | Alt text for the thumbnail. |

| Slot | Slot props | Description | | --- | --- | --- | | default | none | The embed. Rendered only when allowed. | | placeholder | { vendor, vendorName, text, load, open } | Your own placeholder. load() grants this vendor (Load once), open() opens the settings layer, text is the localized notice with the vendor name filled in. |

The default placeholder shows the thumbnail, the notice and the Load once and Settings buttons. The buttons are hidden in editor context and when no policy is loaded.

<ConsentPreferencesLink>

A <button> that reopens the settings layer with the current decisions preset. Put it in the footer and on the privacy page. It is not rendered in editor context, nor while no policy is loaded (not configured, nothing published, gateway unreachable), since there is no choice to open.

| Slot | Slot props | Description | | --- | --- | --- | | default | { text } | The label. Defaults to the policy's privacy_link text or "Cookie settings". |

useConsents()

Headless access for a theme that builds its own UI. Auto-imported. Call it in setup.

const consents = useConsents()

consents.decided.value                // has the visitor decided under the current policy?
consents.state.value                  // { purposes: { statistics: 'granted', … }, vendors: { youtube: 'granted' } }
consents.allows('youtube')            // any purpose of the vendor allowed?
consents.allows('google-analytics', 'statistics')
await consents.acceptAll()
await consents.save({ purposes: { statistics: 'granted', marketing: 'denied' } })
consents.open()                       // reopen the settings layer (as the privacy link does)

| Member | Type | Description | | --- | --- | --- | | state | ComputedRef<ConsentState> | Per purpose and per vendor: 'granted' \| 'denied' \| 'objected'. Never contains the consent id. | | decided | ComputedRef<boolean> | false while a decision is owed. | | policy | ShallowRef<DeliveredPolicy \| null> | The delivered policy (null when none could be loaded). | | isOpen | Ref<boolean> | Whether the banner was opened explicitly. | | locale | Ref<string> | The policy locale in use. | | allows | (vendor: string, purpose?: string) => boolean | The gating rule. Without purpose: any of the vendor's purposes. | | trigger | (vendor: string, purpose?: string) => Promise<void> & { consented: Ref<boolean> } | A @nuxt/scripts consent trigger. Without purpose, the vendor's first purpose is used. | | googleSignals | () => Record<GoogleSignal, 'granted' \| 'denied'> | The current Consent Mode signals. | | open | () => void | Open the settings layer. The resulting record names the surface privacy_link. | | close | () => void | Close the banner once a decision exists. | | acceptAll / rejectAll | () => Promise<void> | Decide. | | save | (choices: { purposes?: Record<string, Decision>, vendors?: Record<string, Decision> }) => Promise<void> | Store a custom decision. | | grantVendor | (vendor: string) => Promise<void> | Load once for one vendor. | | withdraw | () => Promise<void> | Withdraw everything and start a new consent id. | | onChange | (cb: (state: ConsentState) => void) => () => void | Subscribe. Returns an unsubscribe function. |

The locale is taken from @nuxtjs/i18n if it is installed, otherwise from Accept-Language (server) or navigator.language (client). The policy locale is matched exactly, then by language, then falls back to de, then to the first locale the policy has.

The consent provider

nuxtApp.$consentProvider is the only thing a script loader needs. It is the seam @revenexx/tag-manager-nuxt uses, and you can use it with @nuxt/scripts directly:

interface ConsentProvider {
  readonly ready: Promise<void>
  allows: (vendor: string, purpose: string) => boolean
  trigger: (vendor: string, purpose: string) => Promise<void> & { consented: Ref<boolean> }
  googleSignals: () => Record<GoogleSignal, 'granted' | 'denied'>
  onChange: (cb: (state: ConsentState) => void) => () => void
}
  • ready resolves once the policy and the cookie are read, before any component renders.
  • trigger() wraps useScriptTriggerConsent from @nuxt/scripts. It resolves the moment the vendor becomes allowed, so the script loads without a reload. If the vendor is never allowed, it never resolves.
  • The plugin runs with enforce: 'pre', so the provider exists before your own plugins run.

Events and hooks

Three ways to hear about a decision. None of them carries the visitor's consent id.

// 1. Nuxt runtime hook (typed)
nuxtApp.hook('consent-manager:change', (state) => { /* ConsentState */ })

// 2. DOM event, for code outside Vue (a theme extension, a plain script)
window.addEventListener('revenexx:consent', (e) => {
  const state = (e as CustomEvent).detail // ConsentState
})

// 3. Subscription
const off = useNuxtApp().$consentProvider.onChange((state) => { /* … */ })

They fire after each decision (accept, reject, save, load once, withdraw), not on page load. To read the state on page load, use useConsents().state.

Google Consent Mode v2

While the policy's google_consent_mode is basic (also when no policy could be loaded), the module writes an inline script as the first <head> entry (tagPriority: 'critical'):

window.dataLayer = window.dataLayer || []; function gtag(){ dataLayer.push(arguments) }
gtag('consent', 'default', { ad_storage: 'denied', analytics_storage: 'denied', ad_user_data: 'denied',
  ad_personalization: 'denied', functionality_storage: 'denied', personalization_storage: 'denied',
  security_storage: 'granted', wait_for_update: 500 })
gtag('set', 'ads_data_redaction', true)

For a returning visitor, a gtag('consent', 'update', …) with their current signals follows in the same script. Every later decision pushes another update. Which signals a granted purpose releases is set per purpose in the policy (google_signals). Necessary and non-objected legitimate-interest purposes count as granted. With google_consent_mode: off nothing is written.

Load GA4 or GTM through @nuxt/scripts with a trigger from the provider. Do not configure a second consent default in the tag: the default belongs to this module. See docs/recipes.md → Google Consent Mode.

Server routes

The module adds two Nitro routes. The browser talks only to these routes and never to the gateway directly.

| Route | Description | | --- | --- | | GET /_consent-manager/policy | The published policy for the request's tenant and market, from GET {apiUrl}/v1/consent-manager/delivery/policy. Cached in-process for 60 s per tenant + market, with concurrent requests sharing one gateway call. Failures are not cached. "Nothing published" (404) is. ?preview=<token> reads /v1/consent-manager/delivery/preview/{token} uncached. Always answers 200 with { policy, reason }, where reason is null, not_configured, nothing_published, unavailable or preview_unavailable. | | POST /_consent-manager/record | One decision, forwarded to POST {apiUrl}/v1/consent-manager/records. Bodies are limited to 4 KB (413), must be JSON (400) and need valid UUIDs for consent_id and policy_version_id (422). Only the fields the app accepts are forwarded. The page path loses its query and fragment, and the browser is reduced to family + major version (Chrome 129). One record per consent id per second (429), and an identical repeat is answered { ok: true, deduplicated: true } without storing it. With no credentials the route answers 503. |

If writing a record fails, the decision still applies in the browser. The record is kept in localStorage (rvx_consent_pending, the last 10) and sent again on the next page view.

Recipes

Customising the banner through slots, a fully headless banner, YouTube behind <ConsentGate>, Google Consent Mode with GA4/GTM, the privacy-page link, editor hosts, local development against a tenant and running with the Tag Manager are collected in the cookbook → docs/recipes.md.

TypeScript

The public types are exported from the package root:

import type {
  ConsentProvider, ConsentState, Decision, LegalBasis, GoogleSignal,
  DeliveredPolicy, PolicyPurpose, PolicyVendor, BannerTexts, LocalizedPurpose, LocalizedVendor,
  ConsentCookie, RecordAction, RecordSurface, ModuleOptions,
} from '@revenexx/consent-manager-nuxt'

The module augments #app: NuxtApp.$consentProvider and the runtime hook 'consent-manager:change' are typed.

Compatibility

| | | | --- | --- | | Nuxt | >= 4.0.0 (SSR; the banner is meant to be server-rendered) | | @nuxt/scripts | ^1.0.0 (peer) | | Node | >= 20 | | Policy source | the revenexx Consent Manager app, reached through api.revenexx.com |

Troubleshooting

  • No banner at all. Open /_consent-manager/policy in the browser and look at reason: not_configured means no credentials (see Credentials), nothing_published means no policy has been published for this tenant and market, and unavailable means the gateway could not be reached. Also check you are not on an editor host or path (*.theme.rvnxx.site, /admin, /preview).
  • A script never loads after "Accept all". The vendor and purpose codes passed to trigger() or <ConsentGate> must exist in the published policy, and the purpose must be one the vendor serves. An unknown code is never allowed.
  • The page reloads after a decision. This is intended when a decision revokes a vendor that was allowed before, because a loaded script cannot be unloaded. Turn it off with reloadOnRevoke: false if you handle it yourself.
  • The banner shows again for returning visitors. A material policy version was published, or consent_lifetime_days passed. Both are meant to ask again.
  • The banner appears on the privacy page. Add the page's path to banner_exempt_paths in the policy. On those paths the banner is shown as a bar, so the page stays readable.
  • useConsents() needs the @revenexx/consent-manager-nuxt module. The composable was called outside the Nuxt app's component tree, or the module is not in modules.
  • Records fail with 503. The record route has no credentials, so the decision only lives in the cookie. Configure credentials.

Related packages

  • @revenexx/tag-manager-nuxt: loads the marketing tags configured in the revenexx Tag Manager behind this module's $consentProvider. Neither package imports the other.
  • @nuxt/scripts: the loader trigger() plugs into.

A note on legal bases

This module carries out what the published policy states: which purposes exist, which legal basis each one has, which vendors serve them. It does not decide whether a basis fits a given processing, and nothing in this README is legal advice. The merchant chooses and is responsible for the legal bases, texts and vendors in the Consent Manager app.

Development and releasing

pnpm install
pnpm dev:prepare && pnpm dev                    # playground/
pnpm lint && pnpm test:types && pnpm test && pnpm build

See CONTRIBUTING.md. Releases are automated with Changesets and npm trusted publishing, so no tokens are involved:

  1. pnpm changeset: choose a bump and describe the change, then commit the generated file.
  2. On push to main, the Release workflow opens a "Version Packages" PR.
  3. Merging that PR bumps the version, updates CHANGELOG.md and publishes to npm with provenance.

License

MIT © revenexx