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); +});