From 541adf5e0c450e5f05f179cbf426b816373e8599 Mon Sep 17 00:00:00 2001 From: haotian-vapi Date: Sun, 8 Nov 2026 18:14:42 -0800 Subject: [PATCH] [PRO-4275] Enforce docs analytics consent with Fern 5.169.0 [Client consent enforcement](https://linear.app/vapi/issue/PRO-4275/client-consent-enforcement) ## Description Pin Fern 5.169.0 and verify its package hash and both compatibility wrapper markers. Integrate native PostHog consent, latch withdrawal until reload, remove ungated GA4 and unverified custom collectors, and stamp current consent on first-party API requests. ## How it works Native playground requests go directly to Vapi APIs so the consent-header adapter applies. Necessary search remains available; Algolia query analytics, optional identifiers, Insights events, and analytics storage stay disabled even after consent grants. Scoped search authentication and the operational host cache remain intact. The adapter covers the audited SDK transports and retry hosts; upgrades require contract verification. ## Testing Steps - [x] 56 privacy/Fern compatibility tests and 39 subscription checks. - [x] Seven generated-page Chromium scenarios. - [x] Seven generated-playground consent cases; US/EU production preflights accept the headers. Unauthenticated requests return readable 401 responses. - [x] Native search positive control plus unresolved/denied/accepted scenarios with public result fixtures; real anonymous search and navigation with the adapter injected locally. - [x] Fern check: zero errors, 19 warnings; independent implementation review. - [ ] Updated hosted preview: authenticated playground and authenticated search-key identity behavior. - [ ] Published shared Osano Strict-mode/regional configuration and hosted acceptance. The [Validation Report](https://linear.app/vapi/document/pr-26144-validation-report-95d2b5d18e2a) records prior stack validation; current review evidence is retained locally pending publication. Search preservation is verified against the existing live index and native preview UI fixtures; a separately indexed preview is not required for this criterion. Local adapter injection is not deployment verification. PostHog regrant after withdrawal requires reload; Algolia optional analytics remains disabled. The historical 37-probe CLI suite was unavailable; 18 available CLI probes are included above. --- .github/actions/setup-fern/action.yml | 4 +- .github/workflows/check.yml | 22 +++ README.md | 8 +- advanced.md | 6 +- fern/assets/privacy-requests.js | 58 ++++++ fern/assets/privacy-search.js | 171 ++++++++++++++++ fern/assets/privacy.js | 86 +++++++++ fern/custom.js | 109 ----------- fern/docs.yml | 12 +- fern/fern.config.json | 2 +- package-lock.json | 75 +++++++ package.json | 8 + scripts/fern/README.md | 15 +- scripts/fern/example-cache.test.cjs | 2 +- scripts/fern/run.cjs | 8 +- scripts/fern/run.test.cjs | 140 ++++++++++++++ scripts/privacy/README.md | 126 ++++++++++++ scripts/privacy/playwright.config.cjs | 18 ++ scripts/privacy/privacy.browser.cjs | 268 ++++++++++++++++++++++++++ scripts/privacy/privacy.test.cjs | 135 +++++++++++++ scripts/privacy/requests.test.cjs | 92 +++++++++ scripts/privacy/search.test.cjs | 161 ++++++++++++++++ 22 files changed, 1397 insertions(+), 129 deletions(-) create mode 100644 fern/assets/privacy-requests.js create mode 100644 fern/assets/privacy-search.js create mode 100644 fern/assets/privacy.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/fern/run.test.cjs create mode 100644 scripts/privacy/README.md create mode 100644 scripts/privacy/playwright.config.cjs create mode 100644 scripts/privacy/privacy.browser.cjs create mode 100644 scripts/privacy/privacy.test.cjs create mode 100644 scripts/privacy/requests.test.cjs create mode 100644 scripts/privacy/search.test.cjs diff --git a/.github/actions/setup-fern/action.yml b/.github/actions/setup-fern/action.yml index 8f9691c0a..6c88a7490 100644 --- a/.github/actions/setup-fern/action.yml +++ b/.github/actions/setup-fern/action.yml @@ -9,11 +9,11 @@ runs: - name: Install pinned Fern shell: bash run: | - npm install --prefix "$RUNNER_TEMP/vapi-fern" --no-save --no-audit --no-fund fern-api@5.151.1 + npm install --prefix "$RUNNER_TEMP/vapi-fern" --no-save --no-audit --no-fund fern-api@5.169.0 echo "FERN_CLI_PATH=$RUNNER_TEMP/vapi-fern/node_modules/fern-api/cli.cjs" >> "$GITHUB_ENV" - name: Test cache safeguards shell: bash - run: node --test scripts/fern/example-cache.test.cjs + run: node --test scripts/fern/*.test.cjs - uses: actions/cache@v4 with: path: .cache/fern-examples diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b8f63ce4b..5012b1c29 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -17,3 +17,25 @@ jobs: - name: Check API is valid run: node scripts/fern/run.cjs check + + docs-consent: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-fern + - name: Install browser regression dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Test consent and subscription logic + run: | + npm run test:privacy + node fern/custom.spec.js + - name: Verify generated local Fern consent behavior + run: npm run test:privacy:browser + - uses: actions/upload-artifact@v4 + if: always() + with: + name: docs-consent-requests + path: ${{ runner.temp }}/docs-privacy-results + retention-days: 7 diff --git a/README.md b/README.md index f5fdbeae5..bd2f4e0a4 100644 --- a/README.md +++ b/README.md @@ -27,20 +27,22 @@ You can suggest edits by making a pull request. To run a local development server with hot-reloading you can run the following command ```sh -npm install -g fern-api +npm install -g fern-api@5.169.0 fern docs dev ``` +Consent behavior, regression checks, publication prerequisites, and safe disablement are documented in the [docs consent runbook](scripts/privacy/README.md). + #### Hosted URL To update your documentation on a hosted URL, run ``` -# npm install -g fern-api +# npm install -g fern-api@5.169.0 fern generate --docs ``` To preview your documentation, run ``` -# npm install -g fern-api +# npm install -g fern-api@5.169.0 fern generate --docs --preview ``` The repository contains GitHub workflows that will automatically run these commands for you. For example, when you make a PR a preview link will be auto-generated and when you merge to main the docs site will update. diff --git a/advanced.md b/advanced.md index 97583a1fc..a9387a919 100644 --- a/advanced.md +++ b/advanced.md @@ -9,7 +9,7 @@ This repository contains our Fern Configuration: ## Setup ```sh -npm install -g fern-api +npm install -g fern-api@5.169.0 ``` ## Validating your OpenAPI Specs @@ -59,12 +59,12 @@ fern docs dev To update your documentation on a hosted URL, run ``` -# npm install -g fern-api +# npm install -g fern-api@5.169.0 fern generate --docs ``` To preview your documentation, run ``` -# npm install -g fern-api +# npm install -g fern-api@5.169.0 fern generate --docs --preview ``` The repository contains GitHub workflows that will automatically run these commands for you. For example, when you make a PR a preview link will be auto-generated and when you merge to main the docs site will update. diff --git a/fern/assets/privacy-requests.js b/fern/assets/privacy-requests.js new file mode 100644 index 000000000..fd44bb4c0 --- /dev/null +++ b/fern/assets/privacy-requests.js @@ -0,0 +1,58 @@ +// Necessary Vapi API requests still run when optional collection is denied. +// Stamp only the audited API origins, at dispatch, without modifying auth, +// credentials, body, signal, or other caller headers. +(function () { + if (window.__vapiDocsConsentRequests) return; + window.__vapiDocsConsentRequests = true; + function applicable(input) { + try { + var url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url, window.location.href); + return url.origin === 'https://api.vapi.ai' || url.origin === 'https://api.eu.vapi.ai'; + } catch (_) { return false; } + } + function values() { + var state = window.vapiDocsConsent && window.vapiDocsConsent.getSnapshot(); + return { + 'X-Vapi-Consent-Analytics': state && state.ready && state.analytics ? 'granted' : 'denied', + 'X-Vapi-Consent-Marketing': state && state.ready && state.marketing ? 'granted' : 'denied' + }; + } + if (typeof window.fetch === 'function') { + var fetch = window.fetch; + window.fetch = function (input, init) { + if (!applicable(input)) return fetch.call(this, input, init); + var source = init && init.headers !== undefined ? init.headers : input && input.headers; + var headers = new Headers(source); + var consent = values(); + Object.keys(consent).forEach(function (name) { headers.set(name, consent[name]); }); + return fetch.call(this, input, Object.assign({}, init, { headers: headers })); + }; + } + if (typeof XMLHttpRequest !== 'undefined') { + var targets = new WeakMap(); + var open = XMLHttpRequest.prototype.open; + var setHeader = XMLHttpRequest.prototype.setRequestHeader; + var send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function () { + // open() can synchronously fire readystatechange; callers may send from + // that callback, so classify before entering the native method. + targets.set(this, applicable(arguments[1])); + try { return open.apply(this, arguments); } + catch (error) { targets.delete(this); throw error; } + }; + XMLHttpRequest.prototype.setRequestHeader = function (name, value) { + // These two reserved headers are adapter-owned. Avoid XHR appending a + // stale caller grant to the current value supplied immediately at send. + if (targets.get(this) && /^x-vapi-consent-(analytics|marketing)$/i.test(name)) return; + return setHeader.call(this, name, value); + }; + XMLHttpRequest.prototype.send = function () { + if (targets.get(this)) { + var consent = values(); + var xhr = this; + Object.keys(consent).forEach(function (name) { setHeader.call(xhr, name, consent[name]); }); + } + return send.apply(this, arguments); + }; + } +})(); diff --git a/fern/assets/privacy-search.js b/fern/assets/privacy-search.js new file mode 100644 index 000000000..8e07aefda --- /dev/null +++ b/fern/assets/privacy-search.js @@ -0,0 +1,171 @@ +// Fern's pinned Algolia integration has no public pre-initialization opt-out. +// Keep necessary search, but disable its optional analytics for every visitor. +// Audit these transport/storage contracts whenever the Fern bundle changes. +(function () { + if (window.__vapiDocsSearchPrivacy) return; + window.__vapiDocsSearchPrivacy = true; + var optional = ['userToken', 'authenticatedUserToken', 'analyticsTags']; + var success = JSON.stringify({ status: 200, message: 'OK' }); + function target(input) { + try { + var url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url, window.location.href); + var host = url.hostname.toLowerCase(); + if (url.origin === window.location.origin && url.pathname === '/api/fern-docs/search/v2/key') return { url: url, key: true }; + if (url.protocol !== 'https:' || url.port) return { url: url }; + if (/^(insights|insights\.(us|de))\.algolia\.io$/.test(host) && url.pathname === '/1/events') return { url: url, insights: true }; + if (/^p6vyurbgg0(?:-dsn)?\.algolia\.net$|^p6vyurbgg0-[123]\.algolianet\.com$/.test(host) && /^\/1\/indexes\/[^/]+\/(queries|query)$/.test(url.pathname)) return { url: url, search: true }; + return { url: url }; + } catch (_) { return {}; } + } + function object(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Unsupported Algolia search encoding'); + optional.forEach(function (name) { delete value[name]; }); + if ('requests' in value) { + if (!Array.isArray(value.requests)) throw new TypeError('Unsupported Algolia search encoding'); + delete value.analytics; + delete value.clickAnalytics; + value.requests = value.requests.map(object); + } else { + value.analytics = false; + value.clickAnalytics = false; + } + if ('params' in value) value.params = typeof value.params === 'string' ? parameters(value.params).toString() : object(value.params); + return value; + } + function parameters(value) { + var params = new URLSearchParams(value); + optional.forEach(function (name) { params.delete(name); }); + params.set('analytics', 'false'); + params.set('clickAnalytics', 'false'); + if (params.has('params')) params.set('params', parameters(params.get('params')).toString()); + if (params.has('requests')) params.set('requests', JSON.stringify(JSON.parse(params.get('requests')).map(object))); + return params; + } + function body(value) { + if (value == null || value === '') return value; + if (value instanceof URLSearchParams) return parameters(value); + if (typeof value !== 'string') throw new TypeError('Unsupported Algolia search encoding'); + try { return JSON.stringify(object(JSON.parse(value))); } + catch (error) { + // The pinned client sends JSON. Also support documented form parameters; + // an unknown future encoding must not silently restore optional collection. + if (!value.includes('=') || /^[\s]*[\[{]/.test(value)) throw error; + return parameters(value).toString(); + } + } + function searchURL(info) { + info.url.search = parameters(info.url.search).toString(); + return info.url.href; + } + function optionalHeader(name) { return /^x-algolia-(user-token|usertoken|authenticated-user-token|authenticatedusertoken)$/i.test(name); } + if (typeof window.fetch === 'function') { + var fetch = window.fetch; + window.fetch = function (input, init) { + var info = target(input); + if (info.insights) return Promise.resolve(new Response(success, { status: 200, headers: { 'Content-Type': 'application/json' } })); + if (!info.search && !info.key) return fetch.call(this, input, init); + var receiver = this; + return Promise.resolve().then(async function () { + var request = new Request(input instanceof Request ? input : info.url.href, init); + var headers = new Headers(request.headers); + if (info.key) headers.delete('X-User-Token'); + if (info.search) Array.from(headers.keys()).forEach(function (name) { if (optionalHeader(name)) headers.delete(name); }); + var next = new Request(info.search ? searchURL(info) : info.url.href, request); + var options = { headers: headers }; + if (info.search && request.method !== 'GET' && request.method !== 'HEAD') options.body = body(await request.clone().text()); + return fetch.call(receiver, new Request(next, options)); + }); + }; + } + if (navigator && typeof navigator.sendBeacon === 'function') { + var beacon = navigator.sendBeacon; + navigator.sendBeacon = function (url, data) { + if (target(url).insights) return true; // Accepted locally: do not trigger SDK fallback. + return beacon.call(this, url, data); + }; + } + if (typeof XMLHttpRequest !== 'undefined') { + var targets = new WeakMap(); + var localResponses = new WeakSet(); + function clearLocalResponse(xhr) { + if (!localResponses.has(xhr)) return; + ['readyState', 'status', 'statusText', 'responseText', 'response'].forEach(function (name) { delete xhr[name]; }); + localResponses.delete(xhr); + } + var open = XMLHttpRequest.prototype.open; + var send = XMLHttpRequest.prototype.send; + var header = XMLHttpRequest.prototype.setRequestHeader; + var abort = XMLHttpRequest.prototype.abort; + XMLHttpRequest.prototype.open = function (method, url) { + // Remove a prior local response before reusing this native XHR instance. + clearLocalResponse(this); + var info = target(url); + targets.set(this, info); + var args = Array.prototype.slice.call(arguments); + if (info.search) args[1] = searchURL(info); + try { return open.apply(this, args); } + catch (error) { targets.delete(this); throw error; } + }; + XMLHttpRequest.prototype.setRequestHeader = function (name, value) { + var info = targets.get(this); + if (info && ((info.key && /^x-user-token$/i.test(name)) || (info.search && optionalHeader(name)))) return; + return header.call(this, name, value); + }; + XMLHttpRequest.prototype.abort = function () { + targets.delete(this); + clearLocalResponse(this); + return abort.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function (data) { + var info = targets.get(this); + if (!info || !info.insights) return send.call(this, info && info.search ? body(data) : data); + var xhr = this; + // The SDK's XHR sender requires readyState=4, HTTP 200, and JSON success. + // Settle asynchronously without invoking native send or a retrying error. + setTimeout(function () { + if (targets.get(xhr) !== info) return; + localResponses.add(xhr); + Object.defineProperties(xhr, { + readyState: { configurable: true, value: 4 }, status: { configurable: true, value: 200 }, + statusText: { configurable: true, value: 'OK' }, + responseText: { configurable: true, value: success }, + response: { configurable: true, value: xhr.responseType === 'json' ? JSON.parse(success) : success } + }); + ['readystatechange', 'load', 'loadend'].forEach(function (name) { + if (targets.get(xhr) === info) xhr.dispatchEvent(new Event(name)); + }); + }, 0); + }; + } + // Only audited optional keys: do not disturb Algolia's necessary host cache. + var keys = ['algolia-user-token', 'AlgoliaObjectQueryCache']; + if (typeof Storage !== 'undefined') { + var get = Storage.prototype.getItem; + var set = Storage.prototype.setItem; + var remove = Storage.prototype.removeItem; + ['localStorage', 'sessionStorage'].forEach(function (name) { + try { keys.forEach(function (key) { remove.call(window[name], key); }); } catch (_) { /* Storage may be unavailable. */ } + }); + Storage.prototype.getItem = function (key) { return keys.includes(String(key)) ? null : get.apply(this, arguments); }; + Storage.prototype.setItem = function (key) { if (!keys.includes(String(key))) return set.apply(this, arguments); }; + } + if (typeof Document !== 'undefined') { + var cookie = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie'); + if (cookie && cookie.get && cookie.set && cookie.configurable) { + // Remove the known cookie at the current and ancestor domain scopes. + var domains = window.location.hostname.split('.'); + try { + cookie.set.call(document, '_ALGOLIA=; Max-Age=0; Path=/'); + while (domains.length > 1) { + cookie.set.call(document, '_ALGOLIA=; Max-Age=0; Path=/; Domain=' + domains.join('.')); + domains.shift(); + } + } catch (_) { /* Cookies may be unavailable. */ } + Object.defineProperty(Document.prototype, 'cookie', { + configurable: true, enumerable: cookie.enumerable, + get: function () { return cookie.get.call(this).split(';').filter(function (part) { return !/^\s*_ALGOLIA=/.test(part); }).join(';'); }, + set: function (value) { if (!/^\s*_ALGOLIA=/.test(String(value))) cookie.set.call(this, value); } + }); + } + } +})(); diff --git a/fern/assets/privacy.js b/fern/assets/privacy.js new file mode 100644 index 000000000..6cd5a00ab --- /dev/null +++ b/fern/assets/privacy.js @@ -0,0 +1,86 @@ +// Necessary consent control only. Optional SDKs must use this snapshot; do not +// add loaders here without verified startup-race and withdrawal controls. +(function () { + if (window.vapiDocsConsent) return; + + var snapshot = Object.freeze({ ready: false, analytics: false, marketing: false, personalization: false, generation: 0 }); + var listeners = new Set(); + var pendingDenials = {}; + var fernGranted = false; + var fernWithdrawn = false; + window.fernAnalyticsConsent = false; + + // This adapter is the only application Google-consent writer. The Osano + // automatic bridge must be disabled in the release configuration (runbook). + window.dataLayer = window.dataLayer || []; + function googleConsent(command, state) { + function gtag() { window.dataLayer.push(arguments); } + gtag('consent', command, { + analytics_storage: state.analytics ? 'granted' : 'denied', + ad_storage: state.marketing ? 'granted' : 'denied', + ad_user_data: state.marketing ? 'granted' : 'denied', + ad_personalization: state.marketing ? 'granted' : 'denied', + personalization_storage: state.personalization ? 'granted' : 'denied' + }); + } + googleConsent('default', snapshot); + + window.vapiDocsConsent = Object.freeze({ + getSnapshot: function () { return snapshot; }, + subscribe: function (listener) { + listeners.add(listener); + return function () { listeners.delete(listener); }; + } + }); + + function readConsent() { + try { return window.Osano.cm.getConsent(); } catch (_) { return undefined; } + } + function valid(value) { return value === 'ACCEPT' || value === 'DENY'; } + function reconcile(consent) { + consent = consent || readConsent(); + if (consent) consent = Object.assign({}, consent, pendingDenials); + var ready = !!consent && valid(consent.ANALYTICS) && valid(consent.MARKETING) && valid(consent.PERSONALIZATION); + var next = { + ready: ready, + analytics: ready && consent.ANALYTICS === 'ACCEPT', + marketing: ready && consent.MARKETING === 'ACCEPT' && consent['OPT-OUT'] !== 'ACCEPT' && consent.OPT_OUT !== 'ACCEPT' && navigator.globalPrivacyControl !== true, + personalization: ready && consent.PERSONALIZATION === 'ACCEPT', + generation: snapshot.generation + 1 + }; + if (next.ready === snapshot.ready && next.analytics === snapshot.analytics && next.marketing === snapshot.marketing && next.personalization === snapshot.personalization) return; + snapshot = Object.freeze(next); + // Fern's native gate reads the property, not event.detail. Latch withdrawal + // even during SDK import/idle startup: no regrant on this page after a grant. + if (fernGranted && !next.analytics) fernWithdrawn = true; + var allowFern = next.analytics && !fernWithdrawn; + if (allowFern) fernGranted = true; + window.fernAnalyticsConsent = allowFern; + window.dispatchEvent(new Event('fern-analytics-consent')); + googleConsent('update', next); + listeners.forEach(function (listener) { listener(snapshot); }); + } + + // Official preload queue closes the late-CMP race without a polling timeout. + window.Osano = window.Osano || function () { window.Osano.data.push(arguments); }; + window.Osano.data = window.Osano.data || []; + window.Osano('onInitialized', reconcile); + window.Osano('onConsentSaved', function (consent) { + pendingDenials = {}; + reconcile(consent); + }); + window.Osano('onOptOut', function () { reconcile(); }); + window.Osano('onConsentChanged', function (change) { + // Unsaved acceptance is not a grant. A toggle to denial takes effect now, + // even if getConsent() still reports the last saved state. + var consent = readConsent(); + if (change && consent) { + ['ANALYTICS', 'MARKETING', 'PERSONALIZATION'].forEach(function (category) { + if (change[category] === 'DENY') pendingDenials[category] = 'DENY'; + }); + if (change['OPT-OUT'] === 'ACCEPT') pendingDenials['OPT-OUT'] = 'ACCEPT'; + if (change.OPT_OUT === 'ACCEPT') pendingDenials.OPT_OUT = 'ACCEPT'; + } + reconcile(consent); + }); +})(); diff --git a/fern/custom.js b/fern/custom.js index 32bb919f0..36af64094 100644 --- a/fern/custom.js +++ b/fern/custom.js @@ -5,10 +5,6 @@ const WIDGET_SCRIPT_URL = isLocalhost ? 'http://localhost:9001/widget.js' : 'https://docs-widget.vercel.app/widget.js'; -const HOCKEYSTACK_API_KEY = '96e358f635f3f5ea7fda26023b10da'; -const REO_CLIENT_ID = '0dc28e3fda800b9'; -const LINKEDIN_PARTNER_ID = '6951196'; - function injectVapiWidget() { console.log('[custom.js] injectVapiWidget called'); if (document.querySelector(WIDGET_TAG)) { @@ -36,106 +32,6 @@ function injectVapiWidget() { console.log('[custom.js] Widget script appended to DOM'); } -function initializeHockeyStack() { - if (isLocalhost) { - console.log('[custom.js] Skipping HockeyStack on localhost'); - return; - } - - var hsscript = document.createElement("script"); - hsscript.id = "wphs"; - hsscript.src = "https://cdn.jsdelivr.net/npm/hockeystack@latest/hockeystack.min.js"; - hsscript.async = 1; - hsscript.dataset.apikey = HOCKEYSTACK_API_KEY; - hsscript.dataset.cookieless = 1; - hsscript.dataset.autoIdentify = 1; - - document.getElementsByTagName('head')[0].append(hsscript); -} - -function initializeReo() { - if (isLocalhost) { - console.log('[custom.js] Skipping Reo on localhost'); - return; - } - - var reoScript = document.createElement("script"); - reoScript.type = "text/javascript"; - reoScript.src = "https://static.reo.dev/" + REO_CLIENT_ID + "/reo.js"; - reoScript.defer = true; - reoScript.onload = function() { - if (typeof Reo !== 'undefined') { - Reo.init({ clientID: REO_CLIENT_ID }); - } - }; - document.head.appendChild(reoScript); -} - -function configurePostHog() { - if (isLocalhost) { - console.log('[custom.js] Skipping PostHog configuration on localhost'); - return; - } - - // Wait for PostHog to be initialized by Fern - const checkPostHog = setInterval(() => { - if (typeof window.posthog !== 'undefined') { - clearInterval(checkPostHog); - - // Configure cross-domain tracking - window.posthog.set_config({ - cross_subdomain_cookie: true, - cross_domain: '.vapi.ai', - persistence: 'localStorage+cookie' - }); - - } - }, 100); - -} - -function initializeHubSpot() { - - if (isLocalhost) { - console.log('[custom.js] Skipping HubSpot configuration on localhost'); - return; - } - - var hubSpotScript = document.createElement("script"); - hubSpotScript.type = "text/javascript"; - hubSpotScript.id = "hs-script-loader"; - hubSpotScript.src = "https://js-na2.hs-scripts.com/244349038.js"; - hubSpotScript.async = true; - hubSpotScript.defer = true; - document.getElementsByTagName('head')[0].appendChild(hubSpotScript); -} - -function initializeLinkedIn() { - if (isLocalhost) { - console.log('[custom.js] Skipping LinkedIn Insight Tag on localhost'); - return; - } - - window._linkedin_partner_id = LINKEDIN_PARTNER_ID; - window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || []; - window._linkedin_data_partner_ids.push(LINKEDIN_PARTNER_ID); - - (function (l) { - if (!l) { - window.lintrk = function (a, b) { - window.lintrk.q.push([a, b]); - }; - window.lintrk.q = []; - } - var s = document.getElementsByTagName('script')[0]; - var b = document.createElement('script'); - b.type = 'text/javascript'; - b.async = true; - b.src = 'https://snap.licdn.com/li.lms-analytics/insight.min.js'; - s.parentNode.insertBefore(b, s); - })(window.lintrk); -} - function initializeSubscribeForm() { // Fern's MDX renderer strips JSX event handlers (onSubmit, onClick), so the // form's validation and submission logic must be attached from plain JS. @@ -222,11 +118,6 @@ function initializeSubscribeForm() { } function initializeAll() { - initializeHockeyStack(); - initializeReo(); - initializeHubSpot(); - initializeLinkedIn(); - configurePostHog(); initializeSubscribeForm(); if (ENABLE_VOICE_WIDGET) { injectVapiWidget(); diff --git a/fern/docs.yml b/fern/docs.yml index 3e4dfba78..1d86ff075 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -30,10 +30,20 @@ experimental: mdx-components: - snippets settings: + require-analytics-consent: true + disable-explorer-proxy: true http-snippets: false dark-mode-code: true css: assets/styles.css js: + - path: ./assets/privacy-search.js + strategy: beforeInteractive + - path: ./assets/privacy.js + strategy: beforeInteractive + - url: https://cmp.osano.com/18N6QTUelY/9fd9583a-77ec-4ecf-a147-069f96fcb514/osano.js + strategy: beforeInteractive + - path: ./assets/privacy-requests.js + strategy: beforeInteractive - path: ./assets/close-playground.js - path: ./custom.js navbar-links: @@ -96,8 +106,6 @@ analytics: posthog: api-key: ${POSTHOG_PROJECT_API_KEY} endpoint: https://us.i.posthog.com - ga4: - measurement-id: G-BVPB7XB842 navigation: - tab: documentation layout: diff --git a/fern/fern.config.json b/fern/fern.config.json index 58c2baf35..cdb74702f 100644 --- a/fern/fern.config.json +++ b/fern/fern.config.json @@ -1,4 +1,4 @@ { "organization": "vapi", - "version": "5.151.1" + "version": "5.169.0" } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..40520a160 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@playwright/test": "1.59.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..6bddbc693 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "scripts": { + "test:privacy": "node --test scripts/privacy/*.test.cjs", + "test:privacy:browser": "playwright test --config scripts/privacy/playwright.config.cjs" + }, + "devDependencies": { "@playwright/test": "1.59.1" } +} diff --git a/scripts/fern/README.md b/scripts/fern/README.md index ee197a375..ad46ddb35 100644 --- a/scripts/fern/README.md +++ b/scripts/fern/README.md @@ -1,7 +1,7 @@ # Fern example cache -Fern 5.151.1 repeatedly generates examples for nested API properties during -validation and publication. The GPT-Live docs publish spent about 24 minutes +The previously pinned Fern 5.151.1 repeatedly generates examples for nested API +properties during validation and publication. The GPT-Live docs publish spent about 24 minutes preparing validation and another 25 minutes building the API navigation. Its final deployment took six seconds. @@ -49,13 +49,20 @@ original Fern commands also removes this optimization. ## Local verification ```sh -npm install --prefix /tmp/vapi-fern --no-save fern-api@5.151.1 +npm install --prefix /tmp/vapi-fern --no-save fern-api@5.169.0 export FERN_CLI_PATH=/tmp/vapi-fern/node_modules/fern-api/cli.cjs -node --test scripts/fern/example-cache.test.cjs +node --test scripts/fern/*.test.cjs node scripts/fern/run.cjs check --local --log-level debug node scripts/fern/run.cjs generate --docs --preview --log-level debug ``` +The CLI compatibility tests exercise the installed bundle when `FERN_CLI_PATH` is set; +without it, the installed-bundle checks are skipped. The wrapper pins Fern 5.169.0 +and verifies CLI SHA-256 +`773ec0a808a2c4d03c894c81c6159f8e3fe8db5a873c287cdce5b140eed8b809`. +The timing measurements below predate this upgrade. + +Preview generation publishes remotely and requires separate authorization. Only use `--preview` for verification. Production publishing remains in the existing main-branch workflow. Logs print cache hits, misses, and uncached diagnostic-producing calls at exit. diff --git a/scripts/fern/example-cache.test.cjs b/scripts/fern/example-cache.test.cjs index 405e6e8bc..fd152c2d9 100644 --- a/scripts/fern/example-cache.test.cjs +++ b/scripts/fern/example-cache.test.cjs @@ -94,7 +94,7 @@ test('corrupt entries fall back to original generation', (t) => { test('API and prose edits preserve the namespace while tooling changes invalidate it', (t) => { const { directory } = fixture(t); fs.mkdirSync(path.join(directory, 'fern/apis/api'), { recursive: true }); - fs.writeFileSync(path.join(directory, 'fern/fern.config.json'), '{"version":"5.151.1"}'); + fs.writeFileSync(path.join(directory, 'fern/fern.config.json'), '{"version":"5.169.0"}'); fs.writeFileSync(path.join(directory, 'fern/apis/api/openapi.json'), '{}'); const before = inputDigest(directory); fs.writeFileSync(path.join(directory, 'fern/overview.mdx'), 'new prose'); diff --git a/scripts/fern/run.cjs b/scripts/fern/run.cjs index 5d3e831fb..8857c6662 100644 --- a/scripts/fern/run.cjs +++ b/scripts/fern/run.cjs @@ -4,9 +4,9 @@ const os = require('node:os'); const { spawnSync } = require('node:child_process'); const { digest } = require('./example-cache.cjs'); -const VERSION = '5.151.1'; -const CLI_SHA256 = '1259c794e499472dcd28ed3098ee5dbf6e3245bc7f0dac95faf726683fb4b379'; -const MARKER = 'function gxm({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})'; +const VERSION = '5.169.0'; +const CLI_SHA256 = '773ec0a808a2c4d03c894c81c6159f8e3fe8db5a873c287cdce5b140eed8b809'; +const MARKER = 'function KOm({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})'; const MEDIA_MARKER = 'generateOrValidateExample({schema:t,example:r,generateOptionalProperties:n,exampleGenerationStrategy:i})'; function inputDigest(root) { @@ -27,7 +27,7 @@ function main() { if (digest(source) !== CLI_SHA256 || source.split(MARKER).length !== 2 || source.split(MEDIA_MARKER).length !== 2) throw new Error('Unexpected Fern CLI contents. Refusing to apply the cache adapter.'); const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'vapi-fern-')); const patched = source - .replace(MARKER, 'function gxm(args){return globalThis.__vapiFernExamples(args,()=>vapiOriginalExamples(args))}function vapiOriginalExamples({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})') + .replace(MARKER, 'function KOm(args){return globalThis.__vapiFernExamples(args,()=>vapiOriginalExamples(args))}function vapiOriginalExamples({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})') .replace(MEDIA_MARKER, 'generateOrValidateExample(args){return globalThis.__vapiFernExamples({context:this.context,breadcrumbs:this.breadcrumbs,mediaExampleArgs:args},()=>this.vapiOriginalMediaExample(args))}vapiOriginalMediaExample({schema:t,example:r,generateOptionalProperties:n,exampleGenerationStrategy:i})'); try { const cli = path.join(temporary, 'fern.cjs'); diff --git a/scripts/fern/run.test.cjs b/scripts/fern/run.test.cjs new file mode 100644 index 000000000..09ba2013e --- /dev/null +++ b/scripts/fern/run.test.cjs @@ -0,0 +1,140 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { spawnSync } = require('node:child_process'); +const { digest } = require('./example-cache.cjs'); + +const version = '5.169.0'; +const checksum = '773ec0a808a2c4d03c894c81c6159f8e3fe8db5a873c287cdce5b140eed8b809'; +const marker = 'function KOm({propertySchema:e,breadcrumbs:t,context:r,propertyId:n})'; +const mediaMarker = 'generateOrValidateExample({schema:t,example:r,generateOptionalProperties:n,exampleGenerationStrategy:i})'; +const fixture = `${marker}{return e}class Media{${mediaMarker}{return t}}`; +const wrapper = fs.readFileSync(path.join(__dirname, 'run.cjs'), 'utf8'); + +// Exercise the wrapper's process boundary without running a deployment command. +function harness(options = {}) { + const calls = { removed: [], writes: [], spawns: [] }; + const processMock = { + env: { FERN_CLI_PATH: '/installed/cli.cjs', ...options.env }, + argv: ['node', 'run.cjs', 'check', '--local'], + execPath: process.execPath, versions: process.versions, platform: process.platform, arch: process.arch, + }; + const moduleMock = { exports: {} }; + const source = options.source ?? fixture; + function requireMock(name) { + if (name === 'node:fs') return { + ...fs, + readFileSync(file, encoding) { + if (file === '/installed/cli.cjs') return source; + if (file.endsWith('fern.config.json')) return JSON.stringify({ version: options.version ?? version }); + return fs.readFileSync(file, encoding); + }, + mkdtempSync: () => '/temporary/fern', + writeFileSync: (...args) => calls.writes.push(args), + rmSync: (...args) => calls.removed.push(args), + }; + if (name === './example-cache.cjs') return { digest: value => value === source ? (options.checksum ?? checksum) : digest(value) }; + if (name === 'node:child_process') return { spawnSync: (...args) => { calls.spawns.push(args); return options.result ?? { status: 0 }; } }; + return require(name); + } + requireMock.main = moduleMock; + const context = { require: requireMock, module: moduleMock, __dirname, __filename: path.join(__dirname, 'run.cjs'), process: processMock }; + return { calls, process: processMock, run: () => vm.runInNewContext(wrapper, context) }; +} + +test('rejects a config version mismatch before starting Fern', () => { + const h = harness({ version: '5.151.1' }); + assert.throws(h.run, /Fern version changed/); + assert.equal(h.calls.spawns.length, 0); +}); + +test('requires an explicit installed CLI path', () => { + const h = harness({ env: { FERN_CLI_PATH: undefined } }); + assert.throws(h.run, /Set FERN_CLI_PATH/); + assert.equal(h.calls.writes.length, 0); +}); + +test('rejects a changed bundle checksum before applying patches', () => { + const h = harness({ checksum: 'changed' }); + assert.throws(h.run, /Unexpected Fern CLI contents/); + assert.equal(h.calls.writes.length, 0); +}); + +for (const [label, source] of [ + ['missing property marker', fixture.replace(marker, 'function changed()')], + ['duplicated property marker', fixture + marker], + ['missing media marker', fixture.replace(mediaMarker, 'changed()')], + ['duplicated media marker', fixture + mediaMarker], +]) test(`rejects ${label} even with an accepted checksum`, () => { + const h = harness({ source }); + assert.throws(h.run, /Unexpected Fern CLI contents/); + assert.equal(h.calls.spawns.length, 0); +}); + +test('both emitted wrappers call the cache and preserve original generation', () => { + const h = harness(); + h.run(); + const patched = h.calls.writes[0][1]; + const calls = []; + const context = { __vapiFernExamples(args, generate) { calls.push(args); return generate(); } }; + const result = vm.runInNewContext(`${patched};[KOm({propertySchema:'property'}),new Media().generateOrValidateExample({schema:'media'})]`, context); + assert.deepEqual(Array.from(result), ['property', 'media']); + assert.equal(calls.length, 2); + assert.equal(calls[0].propertySchema, 'property'); + assert.equal(calls[1].mediaExampleArgs.schema, 'media'); +}); + +test('forwards arguments, working directory and cache registration to the child', () => { + const h = harness(); + h.run(); + const [executable, args, options] = h.calls.spawns[0]; + assert.equal(executable, process.execPath); + assert.deepEqual(Array.from(args), ['--require', path.join(__dirname, 'register.cjs'), '/temporary/fern/fern.cjs', 'check', '--local']); + assert.equal(options.cwd, path.resolve(__dirname, '../..')); + assert.equal(options.stdio, 'inherit'); + assert.match(options.env.VAPI_FERN_CACHE_NAMESPACE, /^[a-f0-9]{64}$/); + assert.equal(options.env.VAPI_FERN_CACHE_DIR, path.resolve(__dirname, '../../.cache/fern-examples')); + assert.equal(h.calls.removed.length, 1); +}); + +for (const status of [0, 2, null]) test(`preserves child exit status ${status}`, () => { + const h = harness({ result: { status } }); + h.run(); + assert.equal(h.process.exitCode, status ?? 1); + assert.equal(h.calls.removed.length, 1); +}); + +test('propagates child startup errors and removes the temporary bundle', () => { + const h = harness({ result: { error: new Error('startup failed') } }); + assert.throws(h.run, /startup failed/); + assert.equal(h.calls.removed.length, 1); +}); + +const installed = process.env.FERN_CLI_PATH; +test('official installed bundle matches the reviewed checksum and unique markers', { skip: !installed }, () => { + const source = fs.readFileSync(installed, 'utf8'); + assert.equal(digest(source), checksum); + assert.equal(source.split(marker).length, 2); + assert.equal(source.split(mediaMarker).length, 2); + const packageJson = JSON.parse(fs.readFileSync(path.join(path.dirname(installed), 'package.json'), 'utf8')); + assert.equal(packageJson.version, version); +}); + +for (const args of [['--version'], ['--help'], ['check', '--help'], ['generate', '--help']]) { + test(`actual pinned CLI accepts ${args.join(' ')} through the wrapper`, { skip: !installed }, t => { + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'fern-cli-test-')); + t.after(() => fs.rmSync(temporary, { recursive: true, force: true })); + const result = spawnSync(process.execPath, [path.join(__dirname, 'run.cjs'), ...args], { + env: { ...process.env, TMPDIR: temporary }, encoding: 'utf8', timeout: 30000, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /\[fern-example-cache\]/); + assert.ok(result.stdout.trim()); + if (args[0] === '--version') assert.match(result.stdout, /5\.169\.0/); + assert.deepEqual(fs.readdirSync(temporary), []); + }); +} diff --git a/scripts/privacy/README.md b/scripts/privacy/README.md new file mode 100644 index 000000000..3556d94a4 --- /dev/null +++ b/scripts/privacy/README.md @@ -0,0 +1,126 @@ +# Docs consent + +`fern/docs.yml` requires Fern native analytics consent. `fern/assets/privacy.js` +exports `window.vapiDocsConsent.getSnapshot()` and `subscribe(listener)` (returns +an unsubscribe function). A frozen snapshot contains `ready`, `analytics`, +`marketing`, `personalization`, and monotonically increasing `generation`. +Unresolved/malformed CMP state denies optional collection. Osano owns regional +rules and saved preferences; there are no public-site account lookups. + +The adapter reconciles initialized/saved callbacks, including absent payloads +through `Osano.cm.getConsent()` (see the [official Osano API](https://developers.osano.com/cmp/javascript-api/developer-documentation-consent-javascript-api)). Unsaved acceptance does not grant permission; +a denial toggle stops immediately. Marketing also respects GPC and Osano's +`OPT-OUT`/`OPT_OUT`. Future asynchronous optional integrations must compare the initiating +snapshot generation with the current generation before startup/send; never +queue/replay denied events. Integrations without verified stop behavior stay off. + +Fern 5.169.0's CLI schema documents `settings.require-analytics-consent` and +`window.fernAnalyticsConsent` followed by `window.dispatchEvent(new +Event('fern-analytics-consent'))`. The bridge writes the property before the +event. Initial/late grants activate collection; withdrawal sets false immediately +and latches Fern off until full reload, including withdrawal during SDK import. +The local Astro runtime implements a native denied transport guard and removes +PostHog cookies/local/session storage. Test that runtime on every Fern upgrade. +Necessary framework modules may load before Osano; their presence is not evidence +of optional SDK initialization. Fern emits remote head scripts before local head +scripts, so registration also handles Osano's immediate initialized replay. + +`privacy-requests.js` stamps current effective consent at fetch/XHR dispatch only +to `https://api.vapi.ai` and `https://api.eu.vapi.ai`. Fern's +`settings.disable-explorer-proxy: true` sends native API playground requests +directly to those API origins so this adapter can stamp consent at dispatch. +Other origins, docs framework requests, and CDNs are untouched. Request/init +headers, auth, bodies, credentials, and signals retain native behavior. The two +consent headers are adapter-owned. +This is browser request context only; server optional dispatch enforcement belongs +to PRO-4276. No requests or account actions are created by this adapter. + +Custom HockeyStack, Reo, HubSpot tracking, LinkedIn and cross-domain PostHog +configuration were removed. Native ungated GA4 was removed. GTM-MWQNDPQ2 and +unverified optional integrations remain disabled even after Accept All. Requested +subscription delivery and the existing disabled voice-widget flag are preserved. + +`privacy-search.js` runs before the other local scripts and keeps Algolia optional +analytics off for everyone, including after Accept All/regrant. Necessary search +continues: the audited `P6VYURBGG0` search/retry hosts receive `analytics:false` and +`clickAnalytics:false`, without `userToken`, `authenticatedUserToken`, or +`analyticsTags` in URL, JSON query objects, or encoded query parameters. Query, +filter, ranking, language, authorization, and scoped API keys are retained. +Unknown bodies on these search endpoints fail locally instead of forwarding an +unverified encoding. Insights `/1/events` on `insights.algolia.io` and its audited +`us`/`de` regional hosts settles locally across beacon, fetch and XHR; beacon reports +success to prevent the SDK's fallback send. Existing and future `algolia-user-token`, +`AlgoliaObjectQueryCache` storage and `_ALGOLIA` cookies are suppressed. Necessary +`algolia-client-js-*` host availability storage is preserved. The exact same-origin +`/api/fern-docs/search/v2/key` request omits `X-User-Token`; authentication is preserved. +The adapter does not decode or modify signed scoped keys. Anonymous observed keys +had no embedded identity restriction; authenticated fallback restrictions still +require hosted verification and cannot be removed by changing signed credentials. + +This is a scoped compatibility adapter for Fern's audited Algolia lite 5.49.1 / +Insights transport and Storage-method/cookie contracts, not a generic browser +privacy layer. On every Fern upgrade verify the generated bundle's app ID, retry +hosts, endpoints, payload encodings, cookie/storage access, early script order, +SDK completion/fallback behavior and real search results/click navigation. Do not +enable other search analytics transports without this audit. + +## Validation + +Use Node 22 with the SHA-verified Fern installation from the repository setup: + +```sh +npm ci --ignore-scripts +npx playwright install chromium +npm run test:privacy +node fern/custom.spec.js +npm run test:privacy:browser +``` + +The browser command starts a local Fern docs preview on ports 3212/3213 using +`FERN_CLI_PATH`; no hosted preview or publication is created. Optionally use +`DOCS_CONSENT_URL=http://localhost:3212` with an existing local preview. Browser +coverage includes denied/unresolved storage and request initiation, late/initial +acceptance, withdrawal/reload, delayed SDK import, GPC, API headers, navigation, +search dialog availability, and a requested subscription against an exactly +intercepted endpoint. API-header tests use exactly intercepted synthetic paths; +they never exercise a call, payment, or account change. Local preview omits API +reference generation (`--skip-api`); full playground/auth/credential behavior and +search backend results require release preview verification. + +Osano decisions and collection responses are fixtures; actual generated Fern +HTML, consent runtime, bundled SDK and custom scripts run in Chromium. All request +initiations and failures are recorded, including fulfilled requests. Optional +request checks classify vendor hosts and the Fern analytics proxy; they do not +abort trackers. Synthetic collection/config responses and remote extension scripts (empty JS) +are narrowly fulfilled to avoid sending test telemetry to a live analytics project. Subscription and API service +responses are also local fixtures. These tests prove local runtime enforcement, +not live Osano regional configuration, provider classification, or hosted release +behavior; live session-recorder extension behavior is not certified. CI stores request evidence for seven days. Local artifacts stay in the +temporary directory. + +## Release and safe disablement + +1. Before publication, preserve the existing hosted request/storage baseline and + the published Osano version, regional rules, classifications, and Strict-mode + snapshot. Coordinate disabling Osano's automatic Google-consent bridge so this + adapter is the sole Google-consent writer; this repository change does not + publish Osano settings. No GTM or other disabled tracker may be re-enabled. +2. Validate a hosted preview with the actual published CMP in applicable opt-in + and opt-out regions, saved choices, category combinations, GPC, withdrawal, + regrant/reload, SPA routing, search results and API playground. Confirm necessary + framework/CDN code is allowed by Osano without broad Essential vendor exemptions. + Observe request initiation, failed requests and storage with positive controls; + do not use blanket tracker aborts. Do not submit real forms/calls/payments or + persist production account opt-outs during validation. +3. Release only after hosted/provider checks pass and owners approve the separate + publication. Confirm the served native consent meta, script/config propagation, + absent GA4/GTM/unverified loaders, and withdrawal behavior after caches update. + Current local evidence does not complete hosted/provider acceptance. +4. If analytics enforcement fails, publish a protective docs configuration that + removes the `analytics.posthog` block and changes the bridge's `allowFern` to + unconditional `false`, retaining `require-analytics-consent: true`, Osano, both + consent adapters and necessary functionality. If the generated runtime still + includes internal analytics, verify it stays denied before that publication. + Never revert to an old ungated build or restore removed trackers. Validate the + served artifact and cache propagation; a deployment cannot remotely stop scripts + in tabs already running an old release. Do not add telemetry to measure denials. diff --git a/scripts/privacy/playwright.config.cjs b/scripts/privacy/playwright.config.cjs new file mode 100644 index 000000000..aa176d0b3 --- /dev/null +++ b/scripts/privacy/playwright.config.cjs @@ -0,0 +1,18 @@ +const { defineConfig } = require('@playwright/test'); +const path = require('node:path'); +module.exports = defineConfig({ + testDir: __dirname, + testMatch: 'privacy.browser.cjs', + workers: 1, + timeout: 60000, + reporter: 'list', + outputDir: process.env.DOCS_PRIVACY_OUTPUT_DIR || (process.env.RUNNER_TEMP ? `${process.env.RUNNER_TEMP}/docs-privacy-results` : '/private/tmp/docs-privacy-results'), + use: { baseURL: process.env.DOCS_CONSENT_URL || 'http://localhost:3212', headless: true }, + webServer: process.env.DOCS_CONSENT_URL ? undefined : { + cwd: path.resolve(__dirname, '../..'), + command: 'node scripts/fern/run.cjs docs dev --port 3212 --backend-port 3213 --skip-api --skip-validation', + url: 'http://localhost:3212', + reuseExistingServer: !process.env.CI, + timeout: 120000 + } +}); diff --git a/scripts/privacy/privacy.browser.cjs b/scripts/privacy/privacy.browser.cjs new file mode 100644 index 000000000..5b2058b16 --- /dev/null +++ b/scripts/privacy/privacy.browser.cjs @@ -0,0 +1,268 @@ +const { test, expect } = require('@playwright/test'); +const fs = require('node:fs'); + +const categories = (analytics = 'DENY', marketing = 'DENY', personalization = 'DENY') => ({ ANALYTICS: analytics, MARKETING: marketing, PERSONALIZATION: personalization }); +const requestEvidence = new WeakMap(); +test.beforeEach(async ({ page }) => { + const log = []; + requestEvidence.set(page, log); + page.on('request', request => log.push({ method: request.method(), url: request.url() })); + page.on('requestfailed', request => log.push({ failed: request.failure()?.errorText, url: request.url() })); + page.on('pageerror', error => log.push({ pageError: error.message })); +}); +test.afterEach(async ({ page }, info) => { + fs.mkdirSync(info.outputDir, { recursive: true }); + const evidencePath = info.outputPath('request-initiation.json'); + fs.writeFileSync(evidencePath, JSON.stringify(requestEvidence.get(page), null, 2)); + await info.attach('request-initiation.json', { path: evidencePath, contentType: 'application/json' }); + if (info.status === 'passed') expect(requestEvidence.get(page).filter(item => item.pageError)).toEqual([]); +}); + +const optional = url => /posthog|hockeystack|static\.reo\.dev|hs-scripts|hs-analytics|licdn|googletagmanager|google-analytics|doubleclick/i.test(new URL(url).hostname) || /cdn\.jsdelivr\.net\/npm\/hockeystack/i.test(url); + +// Only CMP decisions and collection responses are fixtures. The docs HTML, +// Fern analytics gate, bundled PostHog SDK, router and custom.js are real. +// Request listeners see initiation even for fulfilled/failed requests. There +// is deliberately no catch-all tracker abort hiding a denied-state request. +async function setup(page, initial = null, gpc = false) { + const requests = []; + page.on('request', request => { if (optional(request.url()) || request.url().includes('/analytics/posthog')) requests.push(request.url()); }); + await page.addInitScript(({ initial, gpc }) => { + window.__cmpConsent = initial; + Object.defineProperty(navigator, 'globalPrivacyControl', { value: gpc }); + }, { initial, gpc }); + await page.route('https://cmp.osano.com/**/osano.js', route => route.fulfill({ + contentType: 'text/javascript', + body: `(() => { + const queued = window.Osano?.data || []; + const callbacks = {}; + function Osano(name, callback) { + (callbacks[name] ||= []).push(callback); + if (name === 'onInitialized') callback(window.__cmpConsent || undefined); + } + Osano.cm = { getConsent: () => window.__cmpConsent || undefined }; + window.Osano = Osano; + window.__cmpEmit = (name, value, payloadless = false) => { + if (name !== 'onConsentChanged' && value) window.__cmpConsent = value; + (callbacks[name] || []).forEach(cb => cb(payloadless ? undefined : value)); + }; + for (const args of queued) Osano(...args); + })();` + })); + // Never transmit synthetic analytics to a live project. This does not mock + // the SDK or hide request initiation; the listener above remains authoritative. + await page.route('https://*.posthog.com/**', route => route.request().resourceType() === 'script' + ? route.fulfill({ contentType: 'application/javascript', body: '' }) + : route.fulfill({ json: { status: 1, featureFlags: {}, errorsWhileComputingFlags: false, sessionRecording: false } })); + await page.goto('/'); + await expect(page.locator('meta[name="fern-analytics-consent"]')).toHaveAttribute('content', 'required'); + await page.waitForFunction(() => !!window.vapiDocsConsent && !!window.__cmpEmit); + return requests; +} +async function save(page, consent, payloadless = false) { + await page.evaluate(({ consent, payloadless }) => window.__cmpEmit('onConsentSaved', consent, payloadless), { consent, payloadless }); +} +async function storage(page) { + return page.evaluate(() => ({ + cookies: document.cookie.split(';').filter(value => /^\s*(__)?ph_/i.test(value)), + local: Object.keys(localStorage).filter(key => /^(__)?ph_/.test(key)), + session: Object.keys(sessionStorage).filter(key => /^(__)?ph_/.test(key)) + })); +} + +test('generated search adapter preserves search responses and auth while optional Algolia transport/storage stays off', async ({ page }) => { + const calls = []; + const endpoint = 'https://p6vyurbgg0-dsn.algolia.net/1/indexes/*/queries'; + await page.addInitScript(() => { + localStorage.setItem('algolia-user-token', 'previous-visitor'); + localStorage.setItem('AlgoliaObjectQueryCache', '{}'); + localStorage.setItem('algolia-client-js-test', 'healthy'); + document.cookie = '_ALGOLIA=previous-visitor; path=/'; + }); + await page.route('https://p6vyurbgg0-dsn.algolia.net/1/indexes/**', async route => { + const headers = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' }; + if (route.request().method() === 'OPTIONS') return route.fulfill({ status: 204, headers }); + calls.push({ url: route.request().url(), body: route.request().postDataJSON(), key: route.request().headers()['x-algolia-api-key'] }); + await route.fulfill({ headers, json: { results: [{ hits: [{ objectID: 'fixture', title: 'Voice' }], nbHits: 1 }] } }); + }); + // Record any leak, but never deliver synthetic Insights to a live project. + await page.route('https://insights.algolia.io/1/events', route => route.fulfill({ json: { status: 200, message: 'OK' } })); + await setup(page, categories()); + expect(await page.evaluate(() => window.__vapiDocsSearchPrivacy)).toBe(true); + for (const analytics of ['DENY', 'ACCEPT', 'DENY', 'ACCEPT']) { + await save(page, categories(analytics)); + const result = await page.evaluate(async endpoint => { + const payload = JSON.stringify({ requests: [{ indexName: 'fixture', params: 'query=voice&filters=lang%3Aen&analytics=true&clickAnalytics=true&userToken=visitor&authenticatedUserToken=account&analyticsTags=%5B%22tag%22%5D' }] }); + const hit = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); xhr.open('POST', endpoint); + xhr.setRequestHeader('X-Algolia-API-Key', 'fixture-key'); + xhr.onload = () => resolve(JSON.parse(xhr.responseText).results[0].hits[0].title); + xhr.onerror = reject; xhr.send(payload); + }); + const fetched = await fetch(new Request(endpoint, { method: 'POST', headers: { 'X-Algolia-API-Key': 'fixture-key' }, body: payload })); + const insights = 'https://insights.algolia.io/1/events'; + const beacon = navigator.sendBeacon(insights, '{}'); + const status = (await fetch(insights, { method: 'POST', body: '{}' })).status; + const xhrStatus = await new Promise(resolve => { + const xhr = new XMLHttpRequest(); xhr.open('POST', insights); + xhr.onload = () => resolve(xhr.status); xhr.send('{}'); + }); + localStorage.setItem('algolia-user-token', 'new-visitor'); + localStorage.setItem('AlgoliaObjectQueryCache', '{}'); + document.cookie = '_ALGOLIA=new-visitor; path=/'; + return { hit, fetched: (await fetched.json()).results[0].hits[0].title, beacon, status, xhrStatus, + token: localStorage.getItem('algolia-user-token'), cache: localStorage.getItem('AlgoliaObjectQueryCache'), + hostCache: localStorage.getItem('algolia-client-js-test'), cookie: document.cookie }; + }, endpoint); + expect(result).toMatchObject({ hit: 'Voice', fetched: 'Voice', beacon: true, status: 200, xhrStatus: 200, token: null, cache: null, hostCache: 'healthy' }); + expect(result.cookie).not.toContain('_ALGOLIA='); + } + expect(calls).toHaveLength(8); + for (const call of calls) { + expect(call.key).toBe('fixture-key'); + const params = new URLSearchParams(call.body.requests[0].params); + expect(params.get('query')).toBe('voice'); expect(params.get('filters')).toBe('lang:en'); + expect(params.get('analytics')).toBe('false'); expect(params.get('clickAnalytics')).toBe('false'); + for (const key of ['userToken', 'authenticatedUserToken', 'analyticsTags']) expect(params.has(key)).toBe(false); + } + expect(requestEvidence.get(page).filter(item => item.url && new URL(item.url).hostname === 'insights.algolia.io')).toEqual([]); +}); + +test('unresolved and payload-less saved denial: no optional requests or storage; navigation/search stay available', async ({ page }) => { + const requests = await setup(page); + await page.waitForTimeout(4200); // exceed Fern idle start so a missing gate fails + expect(requests).toEqual([]); + expect(await storage(page)).toEqual({ cookies: [], local: [], session: [] }); + await save(page, categories(), true); + await page.getByRole('link', { name: 'Support', exact: true }).first().click(); + await expect(page).toHaveURL(/\/support$/); + await expect(page.locator('h1').first()).toBeVisible(); + // The built-in search trigger must remain usable while analytics is denied. + const search = page.getByRole('button', { name: 'Search', exact: true }); + await expect(search).toBeVisible(); + await search.click(); + await expect(page.getByRole('dialog').first()).toBeVisible(); + await page.keyboard.press('Escape'); + expect(requests).toEqual([]); +}); + +test('late grant starts actual SDK; withdrawal blocks fetch/XHR/beacon and clears storage; regrant waits for reload', async ({ page }) => { + const requests = await setup(page, categories()); + await page.waitForTimeout(3500); + expect(requests).toEqual([]); + await save(page, categories('ACCEPT'), true); + await expect.poll(() => requests.length, { timeout: 15000 }).toBeGreaterThan(0); + expect(await page.evaluate(() => window.fernAnalyticsConsent)).toBe(true); + await page.evaluate(() => { + localStorage.setItem('ph_test_posthog', '{}'); + sessionStorage.setItem('ph_test_posthog', '{}'); + document.cookie = 'ph_test_posthog=1; path=/'; + }); + await save(page, categories()); + expect(await page.evaluate(() => window.fernAnalyticsConsent)).toBe(false); + expect(await storage(page)).toEqual({ cookies: [], local: [], session: [] }); + const count = requests.length; + await page.evaluate(async () => { + await fetch('https://us.i.posthog.com/i/v0/e/', { method: 'POST', body: '{}' }); + const xhr = new XMLHttpRequest(); + xhr.open('POST', 'https://us.i.posthog.com/i/v0/e/'); + xhr.send('{}'); + navigator.sendBeacon('https://us.i.posthog.com/i/v0/e/', '{}'); + window.dispatchEvent(new Event('pagehide')); + }); + await save(page, categories('ACCEPT')); + expect(await page.evaluate(() => window.vapiDocsConsent.getSnapshot().analytics)).toBe(true); + expect(await page.evaluate(() => window.fernAnalyticsConsent)).toBe(false); + await page.getByRole('link', { name: 'Support', exact: true }).first().click(); + await page.waitForTimeout(2000); + expect(requests.slice(count)).toEqual([]); + expect(await storage(page)).toEqual({ cookies: [], local: [], session: [] }); + await page.addInitScript(() => { window.__cmpConsent = { ANALYTICS: 'ACCEPT', MARKETING: 'DENY', PERSONALIZATION: 'DENY' }; }); + await page.reload(); + await expect.poll(() => requests.length, { timeout: 15000 }).toBeGreaterThan(count); +}); + +test('resolved initial Analytics acceptance works with GPC; disabled trackers never start after Accept All', async ({ page }) => { + const requests = await setup(page, categories('ACCEPT', 'ACCEPT', 'ACCEPT'), true); + await expect.poll(() => requests.length, { timeout: 15000 }).toBeGreaterThan(0); + const state = await page.evaluate(() => window.vapiDocsConsent.getSnapshot()); + expect(state.analytics).toBe(true); + expect(state.marketing).toBe(false); + expect(state.personalization).toBe(true); + expect(requests.every(url => /posthog/.test(url))).toBe(true); +}); + +test('denied analytics preserves requested subscription form submission', async ({ page }) => { + const requests = await setup(page, categories()); + let submissions = 0; + await page.route('https://customerioforms.com/**', async route => { submissions++; await route.fulfill({ status: 200, body: 'ok' }); }); + await page.goto('/whats-new'); + await expect(page.locator('form.subscribe-form')).toHaveAttribute('data-enhanced', 'true'); + await page.locator('form.subscribe-form input[name="email"]').fill('consent-regression@example.com'); + await page.locator('form.subscribe-form button[type="submit"]').click(); + await expect(page.locator('.subscribe-form-message')).toContainText('Thanks for subscribing'); + expect(submissions).toBe(1); + expect(requests).toEqual([]); +}); + +test('withdrawal during the actual SDK import prevents late startup and denied-event replay', async ({ page }) => { + let release; + let started; + const loading = new Promise(resolve => { started = resolve; }); + const unblock = new Promise(resolve => { release = resolve; }); + await page.route('**/_astro/module.*.js', async route => { + started(); + await unblock; + await route.continue(); + }); + const requests = await setup(page, categories()); + await save(page, categories('ACCEPT')); + await loading; + await save(page, categories()); + release(); + await page.waitForTimeout(3500); + expect(requests).toEqual([]); + expect(await storage(page)).toEqual({ cookies: [], local: [], session: [] }); + await save(page, categories('ACCEPT')); + await page.waitForTimeout(1500); + expect(requests).toEqual([]); + expect(await page.evaluate(() => window.fernAnalyticsConsent)).toBe(false); +}); + +test('actual browser fetch Request and XHR API dispatch carry current consent without affecting auth/body', async ({ page }) => { + await setup(page, categories()); + const dispatched = []; + for (const origin of ['https://api.vapi.ai', 'https://api.eu.vapi.ai']) { + await page.route(`${origin}/__consent-regression`, async route => { + const request = route.request(); + dispatched.push({ url: request.url(), headers: request.headers(), body: request.postData() }); + await route.fulfill({ status: 200, body: 'ok', headers: { 'Access-Control-Allow-Origin': '*' } }); + }); + } + // All API traffic in this test uses synthetic, exactly intercepted endpoints. + await page.evaluate(async () => { + const request = new Request('https://api.vapi.ai/__consent-regression', { + method: 'POST', body: 'fetch-body', headers: { Authorization: 'Bearer fixture', 'X-Vapi-Consent-Analytics': 'granted' } + }); + await fetch(request); + }); + await save(page, categories('ACCEPT', 'ACCEPT')); + await page.evaluate(() => new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', 'https://api.eu.vapi.ai/__consent-regression'); + xhr.setRequestHeader('Authorization', 'Bearer fixture'); + xhr.setRequestHeader('X-Vapi-Consent-Analytics', 'denied'); + xhr.onload = resolve; + xhr.onerror = reject; + xhr.send('xhr-body'); + })); + expect(dispatched).toHaveLength(2); + expect(dispatched[0].headers['x-vapi-consent-analytics']).toBe('denied'); + expect(dispatched[0].headers['x-vapi-consent-marketing']).toBe('denied'); + expect(dispatched[0].headers.authorization).toBe('Bearer fixture'); + expect(dispatched[0].body).toBe('fetch-body'); + expect(dispatched[1].headers['x-vapi-consent-analytics']).toBe('granted'); + expect(dispatched[1].headers['x-vapi-consent-marketing']).toBe('granted'); + expect(dispatched[1].headers.authorization).toBe('Bearer fixture'); + expect(dispatched[1].body).toBe('xhr-body'); +}); diff --git a/scripts/privacy/privacy.test.cjs b/scripts/privacy/privacy.test.cjs new file mode 100644 index 000000000..c62a500c9 --- /dev/null +++ b/scripts/privacy/privacy.test.cjs @@ -0,0 +1,135 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const source = fs.readFileSync(path.join(__dirname, '../../fern/assets/privacy.js'), 'utf8'); +const categories = (analytics = 'DENY', marketing = 'DENY', personalization = 'DENY') => ({ ANALYTICS: analytics, MARKETING: marketing, PERSONALIZATION: personalization }); +function browser({ consent, gpc = false } = {}) { + const events = []; + const window = { dispatchEvent: () => events.push(window.fernAnalyticsConsent) }; + const context = vm.createContext({ window, navigator: { globalPrivacyControl: gpc }, Event: class {} }); + vm.runInContext(source, context); + const callbacks = Object.fromEntries(window.Osano.data.map(args => [...args])); + window.Osano.cm = { getConsent: () => consent }; + return { window, events, callbacks, context, setConsent: value => { consent = value; }, snapshot: () => window.vapiDocsConsent.getSnapshot() }; +} +test('unresolved, failed, malformed CMP deny all without a startup timeout', () => { + const b = browser(); + b.callbacks.onInitialized(); + assert.equal(b.snapshot().ready, false); + assert.equal(b.window.fernAnalyticsConsent, false); + b.setConsent({ ANALYTICS: 'ACCEPT' }); + b.callbacks.onInitialized(); + assert.equal(b.snapshot().analytics, false); + b.window.Osano.cm.getConsent = () => { throw Error('CMP unavailable'); }; + b.callbacks.onConsentSaved(); + assert.equal(b.snapshot().ready, false); +}); +test('resolved default and saved payload-less updates read current consent', () => { + const b = browser({ consent: categories('ACCEPT') }); + b.callbacks.onInitialized(); + assert.equal(b.snapshot().analytics, true); + assert.equal(b.events[0], true, 'property is set before Fern event dispatch'); + b.setConsent(categories()); + b.callbacks.onConsentSaved(); + assert.equal(b.snapshot().analytics, false); + assert.equal(b.events[1], false); +}); +test('late grant starts now; withdrawal increments generation and regrant needs reload', () => { + const b = browser(); + const seen = []; + const unsubscribe = b.window.vapiDocsConsent.subscribe(s => seen.push(s)); + b.callbacks.onInitialized(categories()); + b.callbacks.onConsentSaved(categories('ACCEPT')); + assert.equal(b.window.fernAnalyticsConsent, true); + const granted = b.snapshot(); + b.callbacks.onConsentSaved(categories()); + assert.ok(b.snapshot().generation > granted.generation); + assert.equal(b.window.fernAnalyticsConsent, false); + b.callbacks.onConsentSaved(categories('ACCEPT')); + assert.equal(b.snapshot().analytics, true); + assert.equal(b.window.fernAnalyticsConsent, false); + assert.ok(Object.isFrozen(b.snapshot())); + unsubscribe(); + const count = seen.length; + b.callbacks.onConsentSaved(categories()); + assert.equal(seen.length, count); +}); +test('unsaved grants never authorize; denial toggles stop before save', () => { + const b = browser({ consent: categories('ACCEPT') }); + b.callbacks.onInitialized(); + b.callbacks.onConsentChanged({ ANALYTICS: 'DENY' }); + assert.equal(b.snapshot().analytics, false); + b.callbacks.onConsentChanged({ ANALYTICS: 'ACCEPT' }); + assert.equal(b.snapshot().analytics, false); + b.callbacks.onConsentSaved(); + assert.equal(b.snapshot().analytics, true); + assert.equal(b.window.fernAnalyticsConsent, false); + const denied = browser({ consent: categories() }); + denied.callbacks.onInitialized(); + denied.callbacks.onConsentChanged({ ANALYTICS: 'ACCEPT' }); + assert.equal(denied.snapshot().analytics, false); +}); +test('GPC and browser opt-out restrict Marketing while categories remain independent', () => { + for (const gpc of [false, true]) { + const b = browser({ gpc }); + b.callbacks.onInitialized(categories('ACCEPT', 'ACCEPT', 'ACCEPT')); + assert.equal(b.snapshot().marketing, !gpc); + b.setConsent({ ...categories('ACCEPT', 'ACCEPT', 'ACCEPT'), 'OPT-OUT': 'ACCEPT' }); + b.callbacks.onOptOut(); + assert.equal(b.snapshot().marketing, false); + assert.equal(b.snapshot().analytics, true); + assert.equal(b.snapshot().personalization, true); + const update = [...b.window.dataLayer.at(-1)][2]; + assert.equal(update.ad_storage, 'denied'); + assert.equal(update.ad_user_data, 'denied'); + assert.equal(update.ad_personalization, 'denied'); + assert.equal(update.analytics_storage, 'granted'); + assert.equal(update.personalization_storage, 'granted'); + } +}); +test('duplicate script evaluation preserves snapshot and withdrawal latch', () => { + const b = browser(); + b.callbacks.onInitialized(categories('ACCEPT')); + b.callbacks.onConsentSaved(categories()); + const adapter = b.window.vapiDocsConsent; + vm.runInContext(source, b.context); + assert.equal(b.window.vapiDocsConsent, adapter); + b.callbacks.onConsentSaved(categories('ACCEPT')); + assert.equal(b.window.fernAnalyticsConsent, false); +}); +test('docs config enables native gate and loads preload/CMP before custom functionality', () => { + const config = fs.readFileSync(path.join(__dirname, '../../fern/docs.yml'), 'utf8'); + assert.match(config, /require-analytics-consent: true/); + assert.match(config, /js:\n - path: \.\/assets\/privacy-search\.js\n strategy: beforeInteractive\n - path: \.\/assets\/privacy\.js/); + assert.match(config, /privacy\.js\n strategy: beforeInteractive\n - url: https:\/\/cmp\.osano\.com\//); + assert.doesNotMatch(config, /\n (ga4|gtm):/); + const custom = fs.readFileSync(path.join(__dirname, '../../fern/custom.js'), 'utf8'); + assert.doesNotMatch(custom, /hockeystack|static\.reo|hs-scripts|snap\.licdn|posthog\.set_config/i); + assert.match(custom, /function initializeSubscribeForm\(/); +}); + +test('both documented Osano opt-out spellings restrict Marketing after Accept All', () => { + for (const category of ['OPT-OUT', 'OPT_OUT']) { + const b = browser({ consent: { ...categories('ACCEPT', 'ACCEPT', 'ACCEPT'), [category]: 'ACCEPT' } }); + b.callbacks.onInitialized(); + assert.equal(b.snapshot().marketing, false); + assert.equal(b.snapshot().analytics, true); + b.setConsent(categories('ACCEPT', 'ACCEPT', 'ACCEPT')); + b.callbacks.onConsentSaved(); + assert.equal(b.snapshot().marketing, true); + b.callbacks.onConsentChanged({ [category]: 'ACCEPT' }); + assert.equal(b.snapshot().marketing, false); + } +}); +test('Analytics-only and Personalization-only consent never grant other Google purposes', () => { + for (const consent of [categories('ACCEPT'), categories('DENY', 'DENY', 'ACCEPT')]) { + const b = browser({ consent }); + b.callbacks.onInitialized(); + const google = [...b.window.dataLayer.at(-1)][2]; + assert.equal(google.analytics_storage, consent.ANALYTICS === 'ACCEPT' ? 'granted' : 'denied'); + assert.equal(google.personalization_storage, consent.PERSONALIZATION === 'ACCEPT' ? 'granted' : 'denied'); + for (const field of ['ad_storage', 'ad_user_data', 'ad_personalization']) assert.equal(google[field], 'denied'); + } +}); diff --git a/scripts/privacy/requests.test.cjs b/scripts/privacy/requests.test.cjs new file mode 100644 index 000000000..4fde20b29 --- /dev/null +++ b/scripts/privacy/requests.test.cjs @@ -0,0 +1,92 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const source = fs.readFileSync(path.join(__dirname, '../../fern/assets/privacy-requests.js'), 'utf8'); +function setup() { + let state = { ready: false, analytics: false, marketing: false }; + const calls = []; + class XHR { + constructor() { this.headers = {}; } + open(...args) { this.args = args; this.headers = {}; this.onreadystatechange?.(); } + setRequestHeader(name, value) { this.headers[name] = value; } + send(body) { this.body = body; } + } + const window = { + location: { href: 'https://docs.vapi.ai/quickstart' }, + vapiDocsConsent: { getSnapshot: () => state }, + fetch: (...args) => { calls.push(args); return Promise.resolve('response'); } + }; + const context = vm.createContext({ window, URL, Headers, XMLHttpRequest: XHR }); + vm.runInContext(source, context); + return { window, calls, XHR, update: next => { state = next; } }; +} +test('fetch preserves Request/init semantics and reads permission at dispatch', async () => { + const b = setup(); + const request = new Request('https://api.vapi.ai/__consent-regression', { method: 'POST', body: 'payload', headers: { Authorization: 'test', 'X-Vapi-Consent-Analytics': 'granted' }, credentials: 'include' }); + const signal = new AbortController().signal; + await b.window.fetch(request, { signal }); + const [input, init] = b.calls[0]; + assert.equal(input, request); + assert.equal(init.signal, signal); + assert.equal(init.headers.get('Authorization'), 'test'); + assert.equal(init.headers.get('X-Vapi-Consent-Analytics'), 'denied'); + assert.equal(request.bodyUsed, false); + assert.equal(request.credentials, 'include'); + b.update({ ready: true, analytics: true, marketing: false }); + const override = { 'X-Existing': 'kept' }; + await b.window.fetch(request, { headers: override, method: 'PUT', body: 'override', credentials: 'omit' }); + const second = b.calls[1][1]; + assert.equal(second.headers.get('X-Vapi-Consent-Analytics'), 'granted'); + assert.equal(second.headers.get('X-Vapi-Consent-Marketing'), 'denied'); + assert.equal(second.headers.get('Authorization'), null, 'init.headers overrides Request headers'); + assert.equal(second.headers.get('X-Existing'), 'kept'); + assert.equal(second.body, 'override'); + assert.equal(second.method, 'PUT'); + assert.equal(second.credentials, 'omit'); + assert.deepEqual(override, { 'X-Existing': 'kept' }); +}); +test('only exact HTTPS API origins receive headers', async () => { + const b = setup(); + for (const target of ['https://api.vapi.ai/path', new URL('https://api.eu.vapi.ai/path')]) { + await b.window.fetch(target); + assert.equal(b.calls.at(-1)[1].headers.get('X-Vapi-Consent-Marketing'), 'denied'); + } + for (const target of ['/api/fern-docs/search/v2/key', 'https://docs.vapi.ai', 'https://api.vapi.ai.attacker.test', 'http://api.vapi.ai', 'https://api.vapi.ai:8443', 'https://customerioforms.com', 'https://us.i.posthog.com', 'https://cdn.jsdelivr.net']) { + const init = { headers: { Accept: 'application/json' } }; + await b.window.fetch(target, init); + assert.equal(b.calls.at(-1)[1], init); + } +}); +test('XHR keeps credentials/body/caller headers; opening before grant uses send-time consent', () => { + const b = setup(); + const xhr = new b.XHR(); + xhr.open('POST', 'https://api.eu.vapi.ai/__consent-regression', true, 'user', 'password'); + xhr.withCredentials = true; + xhr.setRequestHeader('Authorization', 'test'); + xhr.setRequestHeader('x-vapi-consent-analytics', 'granted'); + b.update({ ready: true, analytics: false, marketing: true }); + xhr.send('payload'); + assert.equal(xhr.headers['X-Vapi-Consent-Analytics'], 'denied'); + assert.equal(xhr.headers['X-Vapi-Consent-Marketing'], 'granted'); + assert.equal(xhr.headers['x-vapi-consent-analytics'], undefined); + assert.equal(xhr.headers.Authorization, 'test'); + assert.equal(xhr.body, 'payload'); + assert.equal(xhr.withCredentials, true); + assert.deepEqual(xhr.args, ['POST', 'https://api.eu.vapi.ai/__consent-regression', true, 'user', 'password']); + xhr.open('GET', 'https://example.com'); + xhr.setRequestHeader('X-Existing', 'kept'); + xhr.send(); + assert.deepEqual(xhr.headers, { 'X-Existing': 'kept' }); +}); + +test('XHR stamps even when a caller sends synchronously from the open state-change callback', () => { + const b = setup(); + const xhr = new b.XHR(); + xhr.onreadystatechange = () => xhr.send('immediate'); + xhr.open('POST', 'https://api.vapi.ai/__consent-regression'); + assert.equal(xhr.headers['X-Vapi-Consent-Analytics'], 'denied'); + assert.equal(xhr.headers['X-Vapi-Consent-Marketing'], 'denied'); + assert.equal(xhr.body, 'immediate'); +}); diff --git a/scripts/privacy/search.test.cjs b/scripts/privacy/search.test.cjs new file mode 100644 index 000000000..642eed5aa --- /dev/null +++ b/scripts/privacy/search.test.cjs @@ -0,0 +1,161 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const file = path.join(__dirname, '../../fern/assets/privacy-search.js'); +const source = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; +const search = 'https://p6vyurbgg0-dsn.algolia.net/1/indexes/*/queries'; +function browser({ unavailableStorage = false } = {}) { + const sent = []; + class Storage { + constructor() { this.data = new Map([['algolia-user-token', 'old-user'], ['AlgoliaObjectQueryCache', '{}'], ['algolia-client-js-hosts', 'healthy'], ['ordinary', 'keep']]); } + getItem(k) { return this.data.get(String(k)) ?? null; } + setItem(k, v) { this.data.set(String(k), String(v)); } + removeItem(k) { this.data.delete(String(k)); } + } + class XHR extends EventTarget { + open(method, url) { this.url = url; this.readyState = 1; } + setRequestHeader(name, value) { (this.headers ||= {})[name] = value; } + send(body) { sent.push({ type: 'xhr', url: this.url, body, headers: this.headers }); } + abort() { this.readyState = 0; } + dispatchEvent(event) { this['on' + event.type]?.(event); return super.dispatchEvent(event); } + } + class Document {} + let cookie = '_ALGOLIA=old-user; ordinary=keep'; + Object.defineProperty(Document.prototype, 'cookie', { configurable: true, get: () => cookie, set: value => { if (value.startsWith('_ALGOLIA=')) cookie = cookie.split('; ').filter(v => !v.startsWith('_ALGOLIA=')).join('; '); } }); + const document = new Document(); + const navigator = { sendBeacon: (...args) => { sent.push({ type: 'beacon', args }); return true; } }; + const window = { location: new URL('https://docs.vapi.ai/overview'), localStorage: new Storage(), sessionStorage: new Storage(), fetch: async (input, init) => { const req = new Request(input, init); sent.push({ type: 'fetch', url: req.url, body: await req.text(), headers: req.headers, signal: req.signal }); return new Response('{"results":[]}'); } }; + if (unavailableStorage) for (const name of ['localStorage', 'sessionStorage']) Object.defineProperty(window, name, { get() { throw new Error('unavailable'); } }); + Object.assign(window, { navigator, document }); + const context = vm.createContext({ window, navigator, document, Document, Storage, XMLHttpRequest: XHR, URL, URLSearchParams, Request, Response, Headers, Event, setTimeout }); + vm.runInContext(source, context); + return { window, document, navigator, XHR, sent, context }; +} +test('native XHR search keeps ranking/auth inputs and disables every optional query identity', () => { + const b = browser(); + const xhr = new b.XHR(); + xhr.open('POST', search + '?userToken=url-user&analytics=true&x-algolia-api-key=secret'); + xhr.setRequestHeader('X-Algolia-API-Key', 'scoped-key'); + xhr.send(JSON.stringify({ requests: [{ indexName: 'docs', query: 'voice', filters: 'lang:en', analytics: true, clickAnalytics: true, userToken: 'user', authenticatedUserToken: 'account', analyticsTags: ['tag'], params: 'query=voice&filters=lang%3Aen&userToken=hidden&analyticsTags=%5B%22tag%22%5D&analytics=true' }] })); + const call = b.sent[0]; + const url = new URL(call.url); + assert.equal(url.searchParams.get('analytics'), 'false'); + assert.equal(url.searchParams.has('userToken'), false); + assert.equal(url.searchParams.get('x-algolia-api-key'), 'secret'); + const req = JSON.parse(call.body).requests[0]; + assert.equal(req.query, 'voice'); assert.equal(req.filters, 'lang:en'); + assert.equal(req.analytics, false); assert.equal(req.clickAnalytics, false); + for (const key of ['userToken', 'authenticatedUserToken', 'analyticsTags']) assert.equal(key in req, false); + const params = new URLSearchParams(req.params); + assert.equal(params.get('query'), 'voice'); assert.equal(params.get('analytics'), 'false'); + assert.equal(params.has('userToken'), false); assert.equal(params.has('analyticsTags'), false); + assert.equal(call.headers['X-Algolia-API-Key'], 'scoped-key'); +}); +test('fetch supports Request/init and retry hosts, and fails closed for unknown search bodies', async () => { + const b = browser(); + const controller = new AbortController(); + await b.window.fetch(new Request(search.replace('-dsn.algolia.net', '-2.algolianet.com'), { method: 'POST', headers: { authorization: 'keep' }, body: JSON.stringify({ requests: [{ indexName: 'docs', params: { query: 'voice', userToken: 'user' } }] }), signal: controller.signal })); + assert.equal(b.sent[0].headers.get('authorization'), 'keep'); + assert.equal(JSON.parse(b.sent[0].body).requests[0].params.analytics, false); + await assert.rejects(b.window.fetch(search, { method: 'POST', body: 'unrecognized' })); + assert.equal(b.sent.length, 1); +}); +test('Insights beacon/fetch/XHR settle successfully without initiation, even after regrant', async () => { + const b = browser(); + const endpoint = 'https://insights.algolia.io/1/events'; + for (const analytics of [false, true]) { + b.window.vapiDocsConsent = { getSnapshot: () => ({ ready: true, analytics }) }; + assert.equal(b.navigator.sendBeacon(endpoint, '{}'), true); + const response = await b.window.fetch(endpoint, { method: 'POST', body: '{}' }); + assert.equal(response.status, 200); + assert.equal((await response.json()).status, 200); + const xhr = new b.XHR(); + const completed = new Promise(resolve => { xhr.onload = resolve; }); + xhr.open('POST', endpoint); xhr.send('{}'); + await completed; + assert.equal(xhr.readyState, 4); assert.equal(xhr.status, 200); + assert.equal(JSON.parse(xhr.responseText).status, 200); + } + assert.equal(b.sent.length, 0); +}); +test('only audited optional storage is removed and blocked; host cache and ordinary storage survive', () => { + const b = browser(); + for (const store of [b.window.localStorage, b.window.sessionStorage]) { + for (const key of ['algolia-user-token', 'AlgoliaObjectQueryCache']) { + assert.equal(store.data.has(key), false); store.setItem(key, 'new'); assert.equal(store.getItem(key), null); assert.equal(store.data.has(key), false); + } + assert.equal(store.getItem('ordinary'), 'keep'); assert.equal(store.getItem('algolia-client-js-hosts'), 'healthy'); + } + assert.equal(b.document.cookie, 'ordinary=keep'); + b.document.cookie = '_ALGOLIA=again; path=/'; + assert.equal(b.document.cookie, 'ordinary=keep'); +}); +test('scoped-key identifier is stripped only on the exact same-origin endpoint', async () => { + const b = browser(); + const key = 'https://docs.vapi.ai/api/fern-docs/search/v2/key'; + await b.window.fetch(key, { headers: { 'X-User-Token': 'user', authorization: 'Bearer necessary' } }); + assert.equal(b.sent[0].headers.has('X-User-Token'), false); + assert.equal(b.sent[0].headers.get('authorization'), 'Bearer necessary'); + const xhr = new b.XHR(); xhr.open('GET', key); xhr.setRequestHeader('X-User-Token', 'user'); xhr.setRequestHeader('authorization', 'keep'); xhr.send(); + assert.equal(b.sent[1].headers['X-User-Token'], undefined); assert.equal(b.sent[1].headers.authorization, 'keep'); + await b.window.fetch('https://api.vapi.ai/api/fern-docs/search/v2/key', { headers: { 'X-User-Token': 'necessary' } }); + assert.equal(b.sent[2].headers.get('X-User-Token'), 'necessary'); +}); +test('unrelated Algolia endpoints, other applications and ordinary requests are untouched', async () => { + const b = browser(); + for (const url of ['https://other-dsn.algolia.net/1/indexes/*/queries', 'https://p6vyurbgg0.algolia.net/1/keys', 'https://example.com/1/events']) { + await b.window.fetch(url, { method: 'POST', body: 'original' }); + assert.equal(b.sent.at(-1).body, 'original'); + } + assert.equal(b.navigator.sendBeacon('https://example.com/1/events', '{}'), true); + assert.equal(b.sent.length, 4); +}); +test('XHR reuse and abort do not retain local Insights response or endpoint classification', async () => { + const b = browser(); + const xhr = new b.XHR(); + const complete = new Promise(resolve => { xhr.onload = resolve; }); + xhr.open('POST', 'https://insights.us.algolia.io/1/events'); xhr.send('{}'); await complete; + xhr.open('POST', search); xhr.send('{"requests":[{"params":"query=voice"}]}'); + assert.equal(xhr.readyState, 1); assert.equal(JSON.parse(b.sent[0].body).requests[0].analytics, false); + const completed = new Promise(resolve => { xhr.onload = resolve; }); + xhr.open('POST', 'https://insights.algolia.io/1/events'); xhr.send('{}'); await completed; + xhr.abort(); xhr.open('POST', search); xhr.send('{"requests":[{"query":"voice"}]}'); assert.equal(xhr.readyState, 1); + xhr.open('POST', 'https://example.com/ordinary'); xhr.send('untouched'); assert.equal(b.sent.at(-1).body, 'untouched'); + let loaded = false; xhr.onload = () => { loaded = true; }; + xhr.open('POST', 'https://insights.algolia.io/1/events'); xhr.send('{}'); xhr.abort(); + await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(loaded, false); +}); +test('fetch honors init overrides and URL encoded multiqueries', async () => { + const b = browser(); + const input = new Request(search, { method: 'POST', headers: { authorization: 'original' }, body: '{"requests":[{"query":"original"}]}' }); + await b.window.fetch(input, { headers: { authorization: 'override' }, body: new URLSearchParams({ requests: JSON.stringify([{ query: 'override', userToken: 'remove' }]) }).toString() }); + assert.equal(b.sent[0].headers.get('authorization'), 'override'); + const req = JSON.parse(new URLSearchParams(b.sent[0].body).get('requests'))[0]; + assert.equal(req.query, 'override'); assert.equal(req.analytics, false); assert.equal('userToken' in req, false); +}); +test('fetch consumes Request bodies once, preserves signals and removes all identity header aliases', async () => { + const b = browser(); + const controller = new AbortController(); + const input = new Request(search, { method: 'POST', signal: controller.signal, headers: { 'X-Algolia-User-Token': 'user', 'X-Algolia-Authenticated-User-Token': 'account', 'X-Algolia-UserToken': 'alias', authorization: 'keep' }, body: '{"requests":[{"query":"voice"}]}' }); + await b.window.fetch(input); + assert.equal(input.bodyUsed, true, 'match native fetch ownership of a Request body'); + assert.equal(b.sent[0].headers.get('authorization'), 'keep'); + for (const name of ['X-Algolia-User-Token', 'X-Algolia-Authenticated-User-Token', 'X-Algolia-UserToken']) assert.equal(b.sent[0].headers.has(name), false); + controller.abort(); assert.equal(b.sent[0].signal.aborted, true); +}); +test('reopening during local Insights completion does not dispatch stale load events', async () => { + const b = browser(); const xhr = new b.XHR(); let loaded = false; + xhr.onreadystatechange = () => { if (xhr.readyState === 4) { xhr.open('POST', search); xhr.send('{"requests":[{"query":"voice"}]}'); } }; + xhr.onload = () => { loaded = true; }; + xhr.open('POST', 'https://insights.algolia.io/1/events'); xhr.send('{}'); + await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal(loaded, false); assert.equal(b.sent.length, 1); assert.equal(xhr.readyState, 1); +}); +test('unavailable storage does not prevent transport enforcement; duplicate evaluation composes once', async () => { + const b = browser({ unavailableStorage: true }); + const fetch = b.window.fetch; vm.runInContext(source, b.context); assert.equal(b.window.fetch, fetch); + await b.window.fetch(search, { method: 'POST', body: '{"requests":[{"userToken":"remove"}]}' }); + assert.equal(JSON.parse(b.sent[0].body).requests[0].analytics, false); +});