From 09f464b7a15e776fffbc0498c0b58a6f16f2c9a8 Mon Sep 17 00:00:00 2001 From: devswha Date: Sat, 26 Sep 2026 17:13:52 +0900 Subject: [PATCH] refactor(web): remove the unread playground funnel analytics Removes the browser analytics adapter, the same-origin /api/funnel collector, and every track() call site in the playground controller. Nothing in the repository read the Upstash counters and the campaign enum was still pinned to the 2026-09-07 launch. Server-side observability for pro-monitor, checkout UTM forwarding, and audit receipts are unchanged. --- api/funnel.js | 171 ---------- docs/ARCHITECTURE.md | 7 +- docs/operations/README.md | 1 - .../multilingual-funnel-20260907.md | 251 -------------- playground/DESIGN.md | 22 -- playground/README.md | 1 - playground/analytics.js | 109 ------ playground/chatgpt.js | 118 +------ playground/index.html | 1 - scripts/check-architecture.mjs | 1 - scripts/dev-server.mjs | 19 -- src/funnel-analytics.js | 71 ---- tests/e2e/dev-server.test.js | 16 - tests/unit/funnel-analytics.test.js | 285 ---------------- tests/unit/playground-analytics.test.js | 312 ------------------ tests/unit/playground-experience.test.js | 27 +- tests/unit/web-deploy-invariants.test.js | 1 - 17 files changed, 25 insertions(+), 1388 deletions(-) delete mode 100644 api/funnel.js delete mode 100644 docs/operations/multilingual-funnel-20260907.md delete mode 100644 playground/analytics.js delete mode 100644 src/funnel-analytics.js delete mode 100644 tests/unit/funnel-analytics.test.js delete mode 100644 tests/unit/playground-analytics.test.js diff --git a/api/funnel.js b/api/funnel.js deleted file mode 100644 index 04f9d9ae..00000000 --- a/api/funnel.js +++ /dev/null @@ -1,171 +0,0 @@ -// @ts-check -import { funnelCounterKey, validateFunnelEvent } from '../src/funnel-analytics.js'; -import { upstashFetch, upstashOrigin } from '../src/upstash-rest.js'; - -export const config = { api: { bodyParser: false } }; - -const BODY_LIMIT = 4096; -const TTL_SECONDS = 35 * 24 * 60 * 60; -const DEFAULT_EVENTS_PER_DAY = 10_000; -const DAILY_LIMIT_CODE = 'FUNNEL_DAILY_LIMIT'; -const INCREMENT_WITH_DAILY_LIMIT = "local total = redis.call('INCRBY', KEYS[1], ARGV[1]) redis.call('PEXPIRE', KEYS[1], ARGV[2]) if total > tonumber(ARGV[3]) then return -1 end local v = redis.call('INCRBY', KEYS[2], ARGV[1]) redis.call('PEXPIRE', KEYS[2], ARGV[2]) return v"; - -/** @param {unknown} value */ -function headerValue(value) { - if (Array.isArray(value)) return value.length === 1 ? value[0] : null; - return typeof value === 'string' ? value : null; -} - -/** @param {any} req @param {string} name */ -function header(req, name) { - const headers = req?.headers; - if (headers?.get) return headerValue(headers.get(name)); - if (!headers || typeof headers !== 'object') return null; - const key = Object.keys(headers).find((item) => item.toLowerCase() === name); - return key ? headerValue(headers[key]) : null; -} - -/** @param {any} res @param {number} status */ -function respond(res, status) { - res.statusCode = status; - res.setHeader?.('Cache-Control', 'no-store'); - res.setHeader?.('Content-Length', '0'); - res.end?.(); -} - -/** @param {any} req */ -function isSameOriginRequest(req) { - const origin = header(req, 'origin'); - const forwardedProto = header(req, 'x-forwarded-proto'); - const forwardedHost = header(req, 'x-forwarded-host'); - if (!origin || !forwardedProto || !forwardedHost || header(req, 'sec-fetch-site') !== 'same-origin') return false; - if (!/^(https?|HTTPS?)$/.test(forwardedProto) || /[\s,]/.test(forwardedHost)) return false; - try { - const parsed = new URL(origin); - return origin === parsed.origin && parsed.protocol === `${forwardedProto.toLowerCase()}:` && parsed.host.toLowerCase() === forwardedHost.toLowerCase(); - } catch { - return false; - } -} - -/** @param {any} req @param {Record} env */ -function isAllowedDeploymentHost(req, env) { - if (env.VERCEL !== '1' && env.NODE_ENV !== 'production') return true; - const allowed = new Set(); - for (const name of ['VERCEL_URL', 'VERCEL_BRANCH_URL', 'VERCEL_PROJECT_PRODUCTION_URL']) { - const value = env[name]; - if (value && !/[/?#@\s,]/.test(value)) allowed.add(value.toLowerCase()); - } - if (env.PATINA_PUBLIC_BASE_URL) { - try { - const url = new URL(env.PATINA_PUBLIC_BASE_URL); - if (url.protocol === 'https:' && url.pathname === '/' && !url.search && !url.hash) allowed.add(url.host.toLowerCase()); - } catch { - return false; - } - } - const host = header(req, 'x-forwarded-host'); - return Boolean(host && allowed.size > 0 && allowed.has(host.toLowerCase())); -} - -/** @param {any} req */ -async function requestBody(req) { - if (req?.body !== undefined) { - const body = req.body; - if (typeof body === 'string') { - if (Buffer.byteLength(body) > BODY_LIMIT) return { tooLarge: true }; - return { text: body }; - } - if (body instanceof Uint8Array) { - if (body.byteLength > BODY_LIMIT) return { tooLarge: true }; - return { text: Buffer.from(body).toString('utf8') }; - } - try { - const text = JSON.stringify(body); - if (typeof text !== 'string') return { invalid: true }; - if (Buffer.byteLength(text) > BODY_LIMIT) return { tooLarge: true }; - return { text }; - } catch { - return { invalid: true }; - } - } - if (!req || typeof req[Symbol.asyncIterator] !== 'function') return { invalid: true }; - let size = 0; - const chunks = []; - for await (const chunk of req) { - const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk); - size += bytes.byteLength; - if (size > BODY_LIMIT) return { tooLarge: true }; - chunks.push(bytes); - } - return { text: Buffer.concat(chunks).toString('utf8') }; -} - -/** - * Dedicated aggregate-only Upstash REST adapter. It cannot read values or - * accept arbitrary commands, so this endpoint cannot persist request context. - * @param {Record} env - * @param {typeof fetch} fetchImpl - */ -export function createFunnelAggregateStore(env = process.env, fetchImpl = globalThis.fetch) { - const origin = upstashOrigin(env.PATINA_OBSERVABILITY_REST_API_URL); - const token = env.PATINA_OBSERVABILITY_REST_API_TOKEN; - if (!origin || !token || typeof fetchImpl !== 'function') return null; - const configuredLimit = Number(env.PATINA_FUNNEL_EVENTS_PER_DAY || DEFAULT_EVENTS_PER_DAY); - if (!Number.isSafeInteger(configuredLimit) || configuredLimit < 1 || configuredLimit > 1_000_000) return null; - - return { - async increment(key, { ttlSeconds }) { - const ttlMs = Math.ceil(ttlSeconds * 1000); - if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) throw new Error('invalid ttl'); - const parts = key.split(':'); - if (parts.length < 5 || parts[0] !== 'patina' || parts[1] !== 'funnel' || parts[2] !== 'v1') throw new Error('invalid aggregate key'); - const budgetKey = `${parts.slice(0, 4).join(':')}:budget`; - const result = await upstashFetch(origin, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify(['EVAL', INCREMENT_WITH_DAILY_LIMIT, '2', budgetKey, key, '1', String(ttlMs), String(configuredLimit)]), - }, { failureMessage: 'aggregate storage unavailable', fetchImpl }); - if (result?.result === -1) { - const error = /** @type {Error & {code: string}} */ (new Error('aggregate daily limit reached')); - error.code = DAILY_LIMIT_CODE; - throw error; - } - if (!Number.isSafeInteger(result?.result) || result.result < 1) throw new Error('invalid aggregate counter'); - }, - }; -} - -/** - * @param {{env?: Record, aggregateStore?: {increment(key: string, options: {ttlSeconds: number}): Promise|void}|null, now?: () => Date|number|string, fetchImpl?: typeof fetch}} options - */ -export function createFunnelApiHandler({ env = process.env, aggregateStore, now = () => new Date(), fetchImpl = globalThis.fetch } = {}) { - const store = aggregateStore === undefined ? createFunnelAggregateStore(env, fetchImpl) : aggregateStore; - return async (req, res) => { - if (req?.method !== 'POST') return respond(res, 405); - if (!isSameOriginRequest(req)) return respond(res, 403); - if (!isAllowedDeploymentHost(req, env)) return respond(res, 403); - const contentType = header(req, 'content-type'); - if (!contentType || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(contentType)) return respond(res, 400); - const body = await requestBody(req); - if (body.tooLarge) return respond(res, 413); - if (body.invalid || typeof body.text !== 'string') return respond(res, 400); - let event; - try { - event = JSON.parse(body.text); - } catch { - return respond(res, 400); - } - if (!validateFunnelEvent(event)) return respond(res, 400); - if (!store) return respond(res, 503); - try { - await store.increment(funnelCounterKey(event, now()), { ttlSeconds: TTL_SECONDS }); - } catch (error) { - if (/** @type {any} */ (error)?.code === DAILY_LIMIT_CODE) return respond(res, 429); - return respond(res, 503); - } - return respond(res, 204); - }; -} - -export default createFunnelApiHandler(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dc264e2e..19544246 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -161,10 +161,9 @@ private or custom local files never reach a browser bundle. `rewrite-handler.js`, `streaming-api.js` — web / hosted rewrite path - `src/web-config.js`, `web-observability.js`, `rate-limit.js`, `security.js` — web rewrite serving infrastructure -- `src/web-prompt-budget.js`, `web-rewrite-receipt.js`, `pro-monitor.js`, - `funnel-analytics.js` — hosted request-shaped prompt budgets, downloadable - audit receipts, aggregate-only Pro health monitor, and privacy-safe funnel - events (no request content retained) +- `src/web-prompt-budget.js`, `web-rewrite-receipt.js`, `pro-monitor.js` — + hosted request-shaped prompt budgets, downloadable audit receipts, and the + aggregate-only Pro health monitor (no request content retained) - `src/entitlement.js`, `entitlement-polar.js` — server-only Pro entitlement (Polar license-key validation) diff --git a/docs/operations/README.md b/docs/operations/README.md index bb01c1d2..67a3f667 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -16,7 +16,6 @@ directory is excluded from the npm tarball (`package.json` `files`: | `pro-margin-decision-20260729.md` | Pro cap = 100 rewrites/month; cited from `src/web-rewrite-contract.js` | | `serving-engine-cost-20260725.md`, `serving-engine-deepseek-0731-correction-20260803.md` | serving-engine cost and quality measurements cited from `src/` and `.env.example` | | `serving-engine-gemini-3.7-flash-20260813.md` | latest engine decision: 3.7-flash allowlisted opt-in, Pro pin unchanged | -| `multilingual-funnel-20260907.md` | funnel measurement runbook | | `backend-compat-kimi-gemini-agy-20260913.json` | current compatibility record for all five local CLI backends. Quota behavior, other operating systems and gemini-cli API-key mode are not covered | | `pay-b-binding-polar-20260729.json`, `pay-live-runtime-polar-20260729.json` | Polar checkout evidence read by `scripts/checkout-evidence-bindings.mjs` | diff --git a/docs/operations/multilingual-funnel-20260907.md b/docs/operations/multilingual-funnel-20260907.md deleted file mode 100644 index 424607dd..00000000 --- a/docs/operations/multilingual-funnel-20260907.md +++ /dev/null @@ -1,251 +0,0 @@ -# Multilingual funnel measurement — September 7, 2026 - -The browser sends category-only milestones after initialization. Prepared links -select the matching UI language. The implementation tests used local stores, -and this record does not claim production counts or external publication. - -## Existing coverage and the measured gap - -`playground/analytics.js` already sends categorical input, rewrite request, -completion, failure, result-action, checkout-start and tier-selection events. -`src/funnel-analytics.js` validates exact schemas; `api/funnel.js` stores daily -aggregate counters. Those events and their keys remain compatible. - -At base `0133dd8`, `captureUtm()` in `playground/chatgpt.js` holds sanitized -checkout parameters in memory and forwards them to checkout links. Its -character/entropy filter permits values outside a finite campaign list. It -does not measure acquisition. Do not pass `capturedUtm` to analytics. - -The old completion `mode=first` means the request mode, not the first success -on a page. Input-start counts omit people who never type and may count both -hero and chat. Neither supplies an arrival denominator or a reuse count. - -One compact `Funnel Progress` event fills these gaps. Its data is exactly -`{lang, channel, campaign, stage}`. Acquisition dimensions do not multiply -the existing detailed rewrite counters. The API handler needs no change: -its existing validator accepts the added schema and applies the same budget. - -## UI integration - -At the end of successful initialization in `playground/chatgpt.js`, after -language selection, `onLangChange()`, `newConvo()`, `showLanding()` and the -send-control updates, add this single call: - -```js -globalThis.patinaFunnelReady?.(els.lang.value); -``` - -Honor the share link's `lang` with an exact `ko/en/zh/ja` allowlist before -initializing the UI. Base `0133dd8` always selects English; the analytics -adapter deliberately does not treat a URL language as proof of UI language. -The UI controller owns that language-routing choice. Call readiness after the UI -has actually initialized, not when the analytics script loads or a module -starts importing. Repeated readiness calls are harmless and do not reset it. - -Keep the existing terminal call, once per approved attempt: - -```js -track('Rewrite Completed', { - ...telemetry, - latencyBucket: latencyBucket(startedAt), - mpsBand: scoreBand(mps), - fidelityBand: scoreBand(fidelity), -}); -``` - -The existing `terminalTracked` guard deduplicates this call. No additional -success/reuse call is needed. The adapter derives both milestones from valid -completions whose MPS and fidelity bands are not `failed`. Do not emit -`Funnel Progress` directly or replay completion events when rendering history. -Errors, cancellation, input, copy/download/export/audit, checkout clicks and -requests do not advance the milestone state. - -Integration checks must verify initialization in all four languages, one arrival -after readiness, no success after a rejected rewrite, and exactly one reuse -milestone after the second accepted result. Changing language or starting a -new conversation in the same page must not reset the funnel. - -## Counts and denominators - -All three stages use the language and safe acquisition labels captured at -readiness. Subsequent language changes still belong to that arrival group; -the legacy rewrite events continue to report the actual request language. - -| Stage | Count represented within one loaded page | -| --- | --- | -| `arrival` (A) | One UI initialization with a supported language | -| `first-success` (F) | One first successful rewrite after initialization | -| `reuse` (R) | One second successful rewrite after initialization | - -Report first-success rate as F/A, reuse among successful pages as R/F, and -arrival-to-reuse as R/A, always with the counts and selected period. A zero -or unavailable denominator means the rate is unavailable. These are observed -page milestones, not unique people. R measures pages with at least two -successes; it does not count every repeat rewrite. Third and later successes -still emit the existing `Rewrite Completed` event, but no new milestone. - -First, refine and verify modes can all produce accepted results. Two successful -attempts in one conversation qualify as reuse, as do attempts in separate -conversations within the page. Failures before or between them do not qualify. -The adapter depends on the UI's terminal-event guard: it has no request IDs -with which to independently deduplicate arbitrary duplicate completion calls. - -A reload or new tab starts fresh. A page restored from browser back/forward -cache retains its in-memory state. No cross-visit retention, returning-user -rate or cross-device attribution can be claimed. There are no session IDs, -cookies, storage entries or fingerprints in this measurement. - -Daily keys use the server's UTC event date, not an arrival cohort date. A page -can arrive before midnight and succeed after midnight, so daily F/A and R/F -are activity-window ratios, not linked cohort conversions; they may exceed -100%. Wider windows reduce this boundary effect but do not remove it. - -Transport loss, browser blocking, malformed events, absent configuration, -storage failures and the shared daily budget can drop any stage separately. -There is no retry queue, delivery acknowledgment gate or reconciliation. An -unobserved counter does not prove zero activity. The endpoint accepts -categories, not evidence of a human, so automated or forged same-origin -requests can also skew counts. - -GitHub star conversion cannot be inferred from these events. Even a GitHub -link click would not establish a star. Checkout-start counts do not establish -payment. The separate provider-confirmed purchase counter has no per-page -join and cannot attribute a purchase or a star to one of these arrivals. - -## Finite attribution and prepared links - -Only exact decoded `utm_source` values `github`, `blog`, `social`, -`community`, or the fallback `unattributed` become `channel`. Only -`multilingual-20260907` or the fallback `none` become `campaign`. Each field -maps independently. Missing, unknown, empty or duplicated parameters map to -its fallback. Case changes and trailing whitespace are not normalized. -Queries longer than 4096 characters, or inaccessible query strings, use both -fallbacks. Only the safe categories survive parsing in memory. - -UTM medium/content/term, `ref`, arbitrary query fields, path, full URL, -fragment and raw referrer are never sent or stored by this adapter. Do not -put draft text, credentials, email, handles or identifiers into share links. -The existing checkout UTM behavior is a separate path and is unchanged here. - -These four community links are prepared for introduction drafts: - -| Copy language | Prepared link | -| --- | --- | -| KO | `https://patina.vibetip.help/?lang=ko&utm_source=community&utm_campaign=multilingual-20260907` | -| EN | `https://patina.vibetip.help/?lang=en&utm_source=community&utm_campaign=multilingual-20260907` | -| ZH | `https://patina.vibetip.help/?lang=zh&utm_source=community&utm_campaign=multilingual-20260907` | -| JA | `https://patina.vibetip.help/?lang=ja&utm_source=community&utm_campaign=multilingual-20260907` | - -For GitHub, blog or social copy, replace only `utm_source=community` with -`utm_source=github`, `utm_source=blog` or `utm_source=social`, respectively. -That defines 16 tagged links across four languages and four channels, with -one fixed campaign. Unknown future campaigns collapse to `none` until a -reviewed client/server allowlist change; never add per-post identifiers. -The UI language routing must remain compatible with these links. Drafting a -link or introduction does not itself publish a post. - -## Storage, budget and privacy boundaries - -The new schema admits at most 4 languages × 5 channels × 2 campaigns × -3 stages = **120 additional keys per UTC day**. A loaded page generates at -most three additional requests. The existing UTC-day budget is shared with -all browser funnel events: default 10,000 requests accepted for counters per -day, configurable through `PATINA_FUNNEL_EVENTS_PER_DAY` from 1 to 1,000,000. -The atomic store operation increments the budget before checking the limit, -so the budget value includes rejected over-limit attempts that reach it; -it is not the number of successful rewrites or stored events. - -Each counter and the daily budget expire 35 days after their last increment. -The added milestone namespace therefore spans at most 36 UTC date buckets -at once (4,320 possible keys), assuming a normally advancing server clock. -The API makes one aggregate increment per valid request and never persists -the body or request context. Its 4 KiB body limit, same-origin/deployment-host -checks, 429 budget response, and 503 response on missing/unusable storage -remain in force. Intake has no aggregate-read route. - -The browser posts only to `/api/funnel` with `credentials: 'omit'` and -`referrerPolicy: 'no-referrer'`. No text, output, key, IP, ID, raw UTM, raw -referrer or URL becomes a dimension. HTTP infrastructure necessarily handles -connections and headers; these tests establish the application payload and -KV boundary, not an audit of hosting-provider access logs. - -## Aggregate query with existing tooling - -Use Node's built-in fetch and the existing observability Upstash REST store, -from the repository root. Supply `PATINA_OBSERVABILITY_REST_API_URL` and -`PATINA_OBSERVABILITY_REST_API_TOKEN` through the operator's existing trusted -environment. Never print them, paste them in this document, pass them as -command arguments, or enable shell tracing. This recipe does not obtain or -change credentials. It was tested against a mocked store, not production. - -This read-only command enumerates all 120 known milestone keys and the single -budget key for one explicit UTC date in one `MGET`. It never scans unrelated -keys or reads identity, quota, payment-deduplication or log records. It prints -only fixed categorical labels, the selected date, counts and missingness; -errors use a fixed message without response bodies or authorization details. - -```bash -FUNNEL_DAY=2026-09-07 node --input-type=module <<'NODE' -import { FUNNEL_PROGRESS_SCHEMA as schema, funnelCounterKey } from './src/funnel-analytics.js'; -import { createFunnelAggregateStore } from './api/funnel.js'; - -try { - const day = process.env.FUNNEL_DAY; - if (!/^\d{4}-\d{2}-\d{2}$/.test(day || '') || new Date(day).toISOString().slice(0, 10) !== day) throw new Error('date'); - if (!createFunnelAggregateStore(process.env)) throw new Error('configuration'); - const rows = []; - const keys = []; - for (const lang of schema.lang) for (const channel of schema.channel) for (const campaign of schema.campaign) { - rows.push({ lang, channel, campaign }); - for (const stage of schema.stage) { - keys.push(funnelCounterKey({ name: 'Funnel Progress', data: { lang, channel, campaign, stage } }, day)); - } - } - keys.push(`patina:funnel:v1:${day}:budget`); - const response = await fetch(new URL(process.env.PATINA_OBSERVABILITY_REST_API_URL).origin, { - method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000), - headers: { Authorization: `Bearer ${process.env.PATINA_OBSERVABILITY_REST_API_TOKEN}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(['MGET', ...keys]), - }); - if (!response.ok) throw new Error('query'); - const result = (await response.json()).result; - if (!Array.isArray(result) || result.length !== keys.length) throw new Error('shape'); - const counts = result.map((value) => { - if (value === null) return null; - if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value)) throw new Error('count'); - const count = Number(value); - if (!Number.isSafeInteger(count)) throw new Error('count'); - return count; - }); - for (let i = 0; i < rows.length; i++) { - for (let j = 0; j < schema.stage.length; j++) rows[i][schema.stage[j]] = counts[i * schema.stage.length + j]; - } - console.log(JSON.stringify({ day, budget: counts.at(-1), observedMilestoneKeys: counts.slice(0, -1).filter((n) => n !== null).length, rows }, null, 2)); -} catch { - console.error('Funnel aggregate query unavailable; no counts reported.'); - process.exitCode = 1; -} -NODE -``` - -`null` means the key is absent, not a verified zero. An entirely absent day -can mean no arrivals, expired counters, missing UI integration, a deployment -without this code, unavailable storage, or the wrong environment. The -separate log-query service counts HTTP outcomes; it cannot recover these -milestones from request logs or infer their missing denominators. - -## Verification - -Run `node --test tests/unit/funnel-analytics.test.js -tests/unit/playground-analytics.test.js` as one command. Tests cover all 120 -client/server combinations, once-per-page progress, language changes, -failures, unknown/duplicate UTMs, sensitive-data canaries, unchanged legacy -contracts, UTC boundaries, budget sharing, and storage failures. -The integrated UI must pass its browser checks before deployment. - -Local verification on September 7: the 30 focused tests passed, and `npm test` -passed 2,239 tests with two skipped and no failures. Full `npm run lint` -passed. The final prose gate was 10.3%, below 30%. Dependencies were installed with -`npm ci --ignore-scripts --no-audit --no-fund`; no dependency or lockfile changed. -Model-backed example verification is recorded separately; it is not a -prerequisite for collecting these categorical events. diff --git a/playground/DESIGN.md b/playground/DESIGN.md index 39e98de2..c99189cd 100644 --- a/playground/DESIGN.md +++ b/playground/DESIGN.md @@ -160,28 +160,6 @@ flagged border and `aria-invalid` marker apply from the first byte regardless. / `.sec__title` scale. Accent stays patina teal — VDL's single-hot-accent principle, not its red. -## Analytics - -The playground emits aggregate conversion-funnel events through the self-hosted -`/analytics.js` adapter to the same-origin `/api/funnel` endpoint. The adapter -accepts only complete fixed event schemas with fixed categorical values, then -sends exactly `{name,data}` via an exception-isolated JSON `fetch` with omitted -credentials, no referrer, and keepalive delivery. The endpoint independently validates that boundary and -increments a UTC-day aggregate KV counter with a 35-day TTL. Failed validation -and analytics exceptions are ignored. The CSP remains self-only. - -The only events are `Input Started` (surface, language), `Rewrite Requested` -(surface, language, tier, mode, input-length bucket), `Rewrite Completed` -(the request dimensions plus wall-latency, MPS, and fidelity bands), `Rewrite -Failed` (the request dimensions plus latency and outcome), `Result Action`, -`Checkout Started`, and `Tier Selected`. This is intentionally an aggregate -funnel: it never sends raw input or output, API or license keys, identifiers, -URLs, query or referrer values, UTM/attribution values, model/provider names, -prompt/output hashes, or Audit JSON receipts. No provider browser script -collects pageview, URL, referrer, query, or device metadata. Client-side -checkout starts are observable, but `Purchase Completed` remains absent: Polar -checkout is cross-origin and requires provider-side evidence. - > History: an earlier Lovable-style **warm-light** token set (near-white > `#faf9f6` canvas, multi-color aurora) is preserved in git history for this > file; the shipped theme has been the editorial dark set above since the diff --git a/playground/README.md b/playground/README.md index 77d43d43..20b132a5 100644 --- a/playground/README.md +++ b/playground/README.md @@ -14,7 +14,6 @@ that **rewrites** AI-sounding text into something more natural for `ko`, `en`, - Pro recovery, pricing, and settings copy in four languages: [`experience-copy.js`](experience-copy.js). - Contract: [`../src/web-rewrite-contract.js`](../src/web-rewrite-contract.js) — the single source of truth shared by the serverless handler, the web runner, the browser client, and the tests. - Vercel routes: [`../vercel.json`](../vercel.json). -- Analytics shim: [`analytics.js`](analytics.js). ## Rewrite contract + deployment invariants diff --git a/playground/analytics.js b/playground/analytics.js deleted file mode 100644 index da9f896a..00000000 --- a/playground/analytics.js +++ /dev/null @@ -1,109 +0,0 @@ -/* global window */ -// Only categorical values from these allowlists can cross the application boundary. -(function initPatinaAnalytics() { - const values = { - surface: new Set(['hero', 'chat', 'pricing', 'quota', 'controls']), - lang: new Set(['en', 'ko', 'zh', 'ja']), - tier: new Set(['free', 'pro', 'byok']), - mode: new Set(['first', 'refine', 'verify']), - inputBucket: new Set(['0-99', '100-499', '500-1999', '2000+']), - latencyBucket: new Set(['<5s', '5-10s', '10-30s', '30s+']), - mpsBand: new Set(['failed', '70-79', '80-89', '90-100']), - fidelityBand: new Set(['failed', '70-79', '80-89', '90-100']), - outcome: new Set(['preflight', 'stream', 'number-safety', 'scoring', 'floor', 'cancelled', 'unknown', 'quota', 'concurrency', 'service', 'input', 'auth']), - action: new Set(['copy', 'download', 'export', 'audit']), - channel: new Set(['unattributed', 'github', 'blog', 'social', 'community']), - campaign: new Set(['none', 'multilingual-20260907']), - }; - const events = { - 'Input Started': new Set(['surface', 'lang']), - 'Rewrite Requested': new Set(['surface', 'lang', 'tier', 'mode', 'inputBucket']), - 'Rewrite Completed': new Set(['surface', 'lang', 'tier', 'mode', 'inputBucket', 'latencyBucket', 'mpsBand', 'fidelityBand']), - 'Rewrite Failed': new Set(['surface', 'lang', 'tier', 'mode', 'inputBucket', 'latencyBucket', 'outcome']), - 'Result Action': new Set(['action']), - 'Checkout Started': new Set(['surface', 'lang']), - 'Tier Selected': new Set(['tier', 'surface']), - }; - - // Memory belongs to this loaded page only. No cookies, browser storage, - // identifiers, timers, or retry queue; the success count saturates at two. - let arrival = null; - let successes = 0; - - function attribution() { - const fallback = { channel: 'unattributed', campaign: 'none' }; - try { - const search = window.location?.search; - if (typeof search !== 'string' || search.length > 4096) return fallback; - const params = new window.URLSearchParams(search); - const pick = (name, allowed, otherwise) => { - const candidates = params.getAll(name); - return candidates.length === 1 && allowed.has(candidates[0]) ? candidates[0] : otherwise; - }; - return { - channel: pick('utm_source', values.channel, fallback.channel), - campaign: pick('utm_campaign', values.campaign, fallback.campaign), - }; - } catch { - return fallback; - } - } - - function send(name, safeData) { - try { - if (typeof window.fetch === 'function') { - window.fetch('/api/funnel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, data: safeData }), - credentials: 'omit', - referrerPolicy: 'no-referrer', - keepalive: true, - }).catch(() => {}); - } - } catch { - // Analytics must never affect rewriting or checkout. - } - } - - // The controller calls this only AFTER language, controls and UI initialize. - // Later language changes keep the arrival language as the funnel denominator. - window.patinaFunnelReady = function patinaFunnelReady(lang) { - try { - if (arrival || !values.lang.has(lang)) return; - arrival = { lang, ...attribution() }; - send('Funnel Progress', { ...arrival, stage: 'arrival' }); - } catch { /* analytics is optional */ } - }; - - window.patinaTrack = function patinaTrack(name, data) { - try { - // Milestones can only be emitted through the page lifecycle below. - if (!Object.hasOwn(events, name)) return; - const allowedKeys = events[name]; - if (!allowedKeys || !data || typeof data !== 'object' || Array.isArray(data)) return; - const safeData = {}; - for (const [key, value] of Object.entries(data)) { - if (!allowedKeys.has(key) || !values[key]?.has(value)) return; - safeData[key] = value; - } - if (Object.keys(safeData).length !== allowedKeys.size) return; - if (allowedKeys.has('surface')) { - const surfaces = name === 'Checkout Started' ? ['pricing', 'quota'] - : name === 'Tier Selected' ? ['pricing', 'controls'] : ['hero', 'chat']; - if (!surfaces.includes(safeData.surface)) return; - } - - send(name, safeData); - // The controller deduplicates terminal events per request. Failures, - // result actions, and requests never advance these page milestones. - if (name === 'Rewrite Completed' && safeData.mpsBand !== 'failed' - && safeData.fidelityBand !== 'failed' && arrival && successes < 2) { - successes += 1; - send('Funnel Progress', { ...arrival, stage: successes === 1 ? 'first-success' : 'reuse' }); - } - } catch { - // Analytics must never affect rewriting or checkout. - } - }; -}()); diff --git a/playground/chatgpt.js b/playground/chatgpt.js index 38bbb1ae..a9101668 100644 --- a/playground/chatgpt.js +++ b/playground/chatgpt.js @@ -26,50 +26,6 @@ import { // the same globalThis convention — e.g. rewrite-client.js). const { document, Option } = globalThis; const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel)); -const track = (eventName, data) => { - try { - if (typeof globalThis.patinaTrack === 'function') globalThis.patinaTrack(eventName, data); - } catch { /* analytics is optional */ } -}; - -function inputBucket(length) { - if (length < 100) return '0-99'; - if (length < 500) return '100-499'; - if (length < 2000) return '500-1999'; - return '2000+'; -} -function latencyBucket(startedAt) { - const elapsed = Math.max(0, Date.now() - startedAt); - if (elapsed < 5000) return '<5s'; - if (elapsed < 10000) return '5-10s'; - if (elapsed < 30000) return '10-30s'; - return '30s+'; -} -function scoreBand(score) { - const value = Number(score); - if (!Number.isFinite(value) || value < 70 || value > 100) return 'failed'; - if (value < 80) return '70-79'; - if (value < 90) return '80-89'; - return '90-100'; -} -function rewriteData(source, clean, mode, lang = els.lang.value) { - const tier = els.tier.value; - return { surface: source, lang, tier, mode, inputBucket: inputBucket(clean.length) }; -} -function failureOutcome(frame, kind, hasScores) { - const K = REWRITE_ERROR_KINDS; - const status = Number(frame?.status); - const code = typeof frame?.code === 'string' ? frame.code : ''; - if (kind === K.NUMBER_SAFETY || kind === K.NUMBER_SOURCE) return 'number-safety'; - if (kind === K.FLOOR_FAILED) return 'floor'; - if (kind.startsWith('quota_') && kind !== K.QUOTA_CONCURRENT && kind !== K.QUOTA_STORAGE && kind !== K.QUOTA_SECRET) return 'quota'; - if (kind === K.QUOTA_CONCURRENT) return 'concurrency'; - if (kind === K.TEXT_TOO_LONG) return 'input'; - if (status === 401 || status === 403) return 'auth'; - if ([K.IP_UNAVAILABLE, K.QUOTA_STORAGE, K.QUOTA_SECRET, K.SERVICE_UNAVAILABLE].includes(kind)) return 'service'; - if (code === 'stream_failed') return 'stream'; - return hasScores ? 'scoring' : 'unknown'; -} const els = { app: $('#app'), @@ -373,7 +329,6 @@ async function attachReview(convo, message, body, textEl, statusEl) { inner.appendChild(buildUserMsg(copy.requested)); inner.appendChild(resultView.node); const result = await runAttempt({ convo, clean: candidate, reqBody, ...resultView, - telemetry: rewriteData('chat', candidate, REWRITE_MODES.VERIFY, convo.thread.preferences.lang), authorization: tier === WEB_TIERS.PRO ? `Bearer ${state.license}` : undefined, epoch }); if (result?.ok && state.sessionEpoch === epoch && activeConvo() === convo) { message.reviewSelection = undefined; message.reviewCandidate = undefined; @@ -460,10 +415,6 @@ function wireProCta() { btn.removeAttribute('aria-disabled'); btn.classList.remove('is-soon'); btn.textContent = i18n().proBuy; - btn.addEventListener('click', () => { - track('Tier Selected', { tier: 'pro', surface: 'pricing' }); - track('Checkout Started', { surface: 'pricing', lang: els.lang.value }); - }); } else { btn.removeAttribute('href'); btn.removeAttribute('target'); @@ -480,7 +431,6 @@ function quotaUpsell() { a.href = href; a.target = '_blank'; a.rel = 'noreferrer'; - a.addEventListener('click', () => track('Checkout Started', { surface: 'quota', lang: els.lang.value })); } else { a.removeAttribute('href'); a.setAttribute('aria-disabled', 'true'); @@ -493,14 +443,12 @@ function wirePricingCtas() { $('#pro-existing')?.addEventListener('click', openLicenseControls); const free = $('#price-free'); if (free) free.addEventListener('click', () => { - track('Tier Selected', { tier: 'free', surface: 'pricing' }); els.tier.value = WEB_TIERS.FREE; syncTier(); updateHeroSend(); updateChatSend(); showLanding(); globalThis.scrollTo({ top: 0, behavior: 'smooth' }); els.heroInput?.focus(); }); const byok = $('#price-byok'); if (byok) byok.addEventListener('click', () => { - track('Tier Selected', { tier: 'byok', surface: 'pricing' }); els.tier.value = WEB_TIERS.BYOK; syncTier(); updateHeroSend(); @@ -996,7 +944,6 @@ function buildOutputActions(text, receipt = null) { const copy = el('button', 'output-action', i18n().actCopy); copy.type = 'button'; copy.addEventListener('click', async () => { - track('Result Action', { action: 'copy' }); try { await globalThis.navigator.clipboard?.writeText(text); copy.textContent = i18n().actCopied; } catch { copy.textContent = i18n().actCopyFailed; } }); const download = el('button', 'output-action', i18n().actDownload); @@ -1006,16 +953,15 @@ function buildOutputActions(text, receipt = null) { const anchor = el('a'); anchor.href = href; anchor.download = name; anchor.click(); globalThis.URL.revokeObjectURL(href); }; - download.addEventListener('click', () => { track('Result Action', { action: 'download' }); save('patina-rewrite.txt'); }); + download.addEventListener('click', () => save('patina-rewrite.txt')); const exportFile = el('button', 'output-action', i18n().actExport); exportFile.type = 'button'; - exportFile.addEventListener('click', () => { track('Result Action', { action: 'export' }); save('patina-rewrite-export.txt'); }); + exportFile.addEventListener('click', () => save('patina-rewrite-export.txt')); actions.append(copy, download, exportFile); if (receipt) { const audit = el('button', 'output-action', i18n().actAudit); audit.type = 'button'; audit.addEventListener('click', () => { - track('Result Action', { action: 'audit' }); const sortKeys = (value) => { if (Array.isArray(value)) return value.map(sortKeys); if (!value || typeof value !== 'object') return value; @@ -1071,7 +1017,7 @@ function stopActive() { } function signOutLicense() { state.sessionEpoch += 1; - if (active) { active.trackCancelled?.(); active.cancelled = true; active.controller.abort(); active = null; } + if (active) { active.cancelled = true; active.controller.abort(); active = null; } state.busy = false; state.license = ''; state.licenseStatus = licenseStatusAfter(state.licenseStatus, 'clear'); @@ -1173,19 +1119,12 @@ async function submit(text, source = 'hero') { clearInlineErrors(); const currentConvo = activeConvo(); const initialTurn = currentConvo?.thread.original == null; - const preflightLang = initialTurn && !currentConvo?.thread.languageExplicit ? (detectLang(clean) || els.lang.value) : els.lang.value; - const preflightMode = initialTurn ? 'first' : 'refine'; // What the request will actually ask the server to rewrite: the source on a // first turn, the latest accepted draft on a refine turn (where the composer - // line travels as `instruction`). Length caps and analytics buckets measure - // that text, not the follow-up instruction. + // line travels as `instruction`). Length caps measure that text, not the + // follow-up instruction. const target = initialTurn ? clean : (currentConvo?.thread.currentDraft ?? clean); - if (!preflight(target, source)) { - const data = rewriteData(source, target, preflightMode, preflightLang); - track('Rewrite Requested', data); - track('Rewrite Failed', { ...data, latencyBucket: '<5s', outcome: 'preflight' }); - return; - } + if (!preflight(target, source)) return; let convo = activeConvo(); if (!convo) { newConvo(); convo = activeConvo(); } @@ -1217,9 +1156,8 @@ async function submit(text, source = 'hero') { }); reqBody.includeEdits = true; reqBody.protectedSpans = protectedInputSpans(convo.thread.original ?? clean, convo.protectedInput || ''); - const telemetry = rewriteData(source, String(reqBody.text ?? ''), String(reqBody.mode)); await runAttempt({ - convo, clean, reqBody, body, textEl, statusEl, telemetry, + convo, clean, reqBody, body, textEl, statusEl, authorization: tier === WEB_TIERS.PRO ? `Bearer ${state.license}` : undefined, epoch: state.sessionEpoch, }); @@ -1228,26 +1166,8 @@ async function submit(text, source = 'hero') { // One streaming attempt against /api/rewrite. The thread only commits on a // done frame, so a failed or cancelled attempt never poisons conversation state. async function runAttempt(attempt) { - const { convo, clean, reqBody, body, textEl, statusEl, authorization, epoch, telemetry } = attempt; - const startedAt = Date.now(); + const { convo, clean, reqBody, body, textEl, statusEl, authorization, epoch } = attempt; let approved = false; - track('Rewrite Requested', telemetry); - let terminalTracked = false; - const trackFailed = (outcome) => { - if (terminalTracked) return; - terminalTracked = true; - track('Rewrite Failed', { ...telemetry, latencyBucket: latencyBucket(startedAt), outcome }); - }; - const trackCompleted = (mps, fidelity) => { - if (terminalTracked) return; - terminalTracked = true; - track('Rewrite Completed', { - ...telemetry, - latencyBucket: latencyBucket(startedAt), - mpsBand: scoreBand(mps), - fidelityBand: scoreBand(fidelity), - }); - }; state.busy = true; for (const controller of reviewControllers) controller.setBusy(true); if (reqBody.tier === WEB_TIERS.PRO) { @@ -1272,9 +1192,7 @@ async function runAttempt(attempt) { const run = { controller, cancelled: false, - trackCancelled: () => trackFailed('cancelled'), stop: () => { - trackFailed('cancelled'); if (typing.parentElement) typing.remove(); textEl.style.display = ''; textEl.classList.remove('streaming'); @@ -1333,7 +1251,6 @@ async function runAttempt(attempt) { audit.dataset.verificationMeta = 'true'; body.appendChild(audit); if (rejected) { - trackFailed(Number.isFinite(mps) && Number.isFinite(fidelity) ? 'floor' : 'scoring'); textEl.classList.add('msg__text--flagged'); markOutputUnapproved(textEl, statusEl); body.appendChild(errorNote(i18n().floorWarn)); @@ -1341,7 +1258,6 @@ async function runAttempt(attempt) { } approveOutput(textEl, statusEl); approved = true; - trackCompleted(mps, fidelity); body.appendChild(buildOutputActions(rewrite, frame.receipt)); const message = { role: 'assistant', text: rewrite, meta, original, receipt: frame.receipt, editReview: frame.editReview, protectedSpans: reqBody.protectedSpans || [] }; @@ -1368,8 +1284,6 @@ async function runAttempt(attempt) { const hasScores = ff.mps != null || ff.fidelity != null; const kind = classifyRewriteError(ff); const K = REWRITE_ERROR_KINDS; - const outcome = failureOutcome(ff, kind, hasScores); - trackFailed(outcome); if (attemptText || hasScores) { textEl.style.display = ''; textEl.textContent = attemptText || cleanStream(textEl.textContent); @@ -1398,7 +1312,6 @@ async function runAttempt(attempt) { markOutputUnapproved(textEl, statusEl); const t = i18n(); const msg = run.cancelled ? t.stopNote : timedOut ? t.timeoutNote : tfmt(t.netNote, { msg: String(e?.message || e) }); - trackFailed(run.cancelled ? 'cancelled' : 'stream'); body.appendChild(errorNote(msg)); if (!run.cancelled) addRetry(body, attempt); } finally { @@ -1676,13 +1589,6 @@ document.addEventListener('mousedown', (event) => { if (withinSettings(event.target)) return; closeSettings(); }); -const inputStarted = new Set(); -function trackInputStarted(surface, input) { - if (inputStarted.has(surface) || input.value.length === 0) return; - inputStarted.add(surface); - track('Input Started', { surface, lang: els.lang.value }); -} - function submitOnEnter(e, input, source) { // 229 also covers IME boundary keydowns whose isComposing flag is false. if (e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229) return; @@ -1691,11 +1597,11 @@ function submitOnEnter(e, input, source) { } els.heroForm.addEventListener('submit', (e) => { e.preventDefault(); if (state.busy) { stopActive(); return; } submit(els.heroInput.value, 'hero'); }); -els.heroInput.addEventListener('input', () => { trackInputStarted('hero', els.heroInput); autoGrow(els.heroInput); updateHeroSend(); }); +els.heroInput.addEventListener('input', () => { autoGrow(els.heroInput); updateHeroSend(); }); els.heroInput.addEventListener('keydown', (e) => submitOnEnter(e, els.heroInput, 'hero')); els.composer.addEventListener('submit', (e) => { e.preventDefault(); if (state.busy) { stopActive(); return; } submit(els.input.value, 'chat'); }); -els.input.addEventListener('input', () => { trackInputStarted('chat', els.input); autoGrow(els.input); updateChatSend(); }); +els.input.addEventListener('input', () => { autoGrow(els.input); updateChatSend(); }); els.input.addEventListener('keydown', (e) => submitOnEnter(e, els.input, 'chat')); els.newChat.addEventListener('click', () => { if (state.busy) stopActive(); newConvo(); showChat(); els.input.value = ''; autoGrow(els.input); updateChatSend(); closeMobileSidebar(); els.input.focus(); }); @@ -1709,7 +1615,6 @@ els.ctaStart && els.ctaStart.addEventListener('click', () => { globalThis.scroll els.lang.addEventListener('change', onLangChange); for (const control of [els.documentType, els.persona, els.register]) control.addEventListener('change', onPreferencesChange); els.tier.addEventListener('change', () => { - track('Tier Selected', { tier: els.tier.value, surface: 'controls' }); syncTier(); clearInlineErrors(); updateHeroSend(); updateChatSend(); }); els.apiKey.addEventListener('input', () => { @@ -1733,6 +1638,3 @@ newConvo(); showLanding(); updateHeroSend(); updateChatSend(); - -// Arrival uses the initialized browser locale; analytics owns success/reuse counting. -try { globalThis.patinaFunnelReady?.(els.lang.value); } catch { /* optional analytics */ } diff --git a/playground/index.html b/playground/index.html index 9243c273..b956fc56 100644 --- a/playground/index.html +++ b/playground/index.html @@ -295,7 +295,6 @@

Paste your own and see

- diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index b7640292..e640c1fc 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -146,7 +146,6 @@ const SERVER_SECRET_MODULES = Object.freeze([ 'src/config.js', 'src/entitlement.js', 'src/entitlement-polar.js', - 'src/funnel-analytics.js', 'src/quota-reservation.js', 'src/rate-limit.js', 'src/security.js', diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs index 8df31760..0ed3621a 100644 --- a/scripts/dev-server.mjs +++ b/scripts/dev-server.mjs @@ -22,7 +22,6 @@ import { fileURLToPath } from 'node:url'; import { createRewriteHandler } from '../src/rewrite-handler.js'; import { encodeStreamFrame } from '../src/web-rewrite-contract.js'; import { runWebRewriteStream } from '../src/web-rewrite-stream.js'; -import { createFunnelApiHandler } from '../api/funnel.js'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const PUBLIC_ROOT = await realpath(path.join(REPO_ROOT, 'playground')); @@ -60,7 +59,6 @@ const REWRITES = new Map([ ['/chatgpt.js', '/playground/chatgpt.js'], ['/chatgpt.css', '/playground/chatgpt.css'], ['/rewrite-client.js', '/playground/rewrite-client.js'], - ['/analytics.js', '/playground/analytics.js'], ['/launch-config.js', '/playground/launch-config.js'], ]); @@ -388,27 +386,10 @@ const rewriteApi = createRewriteHandler({ // ---------- server ---------- const { port, host } = parseArgs(process.argv.slice(2)); -// Validate browser telemetry with the real contract; preview events are discarded. -const funnelApi = createFunnelApiHandler({ - env: { NODE_ENV: 'development' }, - aggregateStore: { async increment() {} }, -}); - const server = http.createServer((req, res) => { const urlPath = requestPath(req, res); if (urlPath === undefined) return; - if (urlPath === '/api/funnel') { - req.headers['x-forwarded-proto'] = 'http'; - req.headers['x-forwarded-host'] = req.headers.host; - res.setHeader('X-Patina-Mock', '1'); - funnelApi(req, res).catch(() => { - if (!res.headersSent) res.writeHead(500); - res.end(); - }); - return; - } - if (urlPath === '/api/rewrite') { // The fail-closed rate limiter needs a client IP from a trusted header; a // local browser request has none, so inject one for the in-memory quota. diff --git a/src/funnel-analytics.js b/src/funnel-analytics.js deleted file mode 100644 index f08949a3..00000000 --- a/src/funnel-analytics.js +++ /dev/null @@ -1,71 +0,0 @@ -// @ts-check - -export const FUNNEL_SCHEMA_VERSION = 'v1'; - -// A separate compact projection avoids multiplying the detailed rewrite keys -// by acquisition dimensions. At most 4 * 5 * 2 * 3 = 120 keys per UTC day. -export const FUNNEL_PROGRESS_SCHEMA = Object.freeze({ - lang: Object.freeze(['en', 'ko', 'zh', 'ja']), - channel: Object.freeze(['unattributed', 'github', 'blog', 'social', 'community']), - campaign: Object.freeze(['none', 'multilingual-20260907']), - stage: Object.freeze(['arrival', 'first-success', 'reuse']), -}); - -const eventSchemas = { - 'Funnel Progress': FUNNEL_PROGRESS_SCHEMA, - 'Input Started': { surface: ['hero', 'chat'], lang: ['en', 'ko', 'zh', 'ja'] }, - 'Rewrite Requested': { surface: ['hero', 'chat'], lang: ['en', 'ko', 'zh', 'ja'], tier: ['free', 'pro', 'byok'], mode: ['first', 'refine', 'verify'], inputBucket: ['0-99', '100-499', '500-1999', '2000+'] }, - 'Rewrite Completed': { surface: ['hero', 'chat'], lang: ['en', 'ko', 'zh', 'ja'], tier: ['free', 'pro', 'byok'], mode: ['first', 'refine', 'verify'], inputBucket: ['0-99', '100-499', '500-1999', '2000+'], latencyBucket: ['<5s', '5-10s', '10-30s', '30s+'], mpsBand: ['failed', '70-79', '80-89', '90-100'], fidelityBand: ['failed', '70-79', '80-89', '90-100'] }, - 'Rewrite Failed': { surface: ['hero', 'chat'], lang: ['en', 'ko', 'zh', 'ja'], tier: ['free', 'pro', 'byok'], mode: ['first', 'refine', 'verify'], inputBucket: ['0-99', '100-499', '500-1999', '2000+'], latencyBucket: ['<5s', '5-10s', '10-30s', '30s+'], outcome: ['preflight', 'stream', 'number-safety', 'scoring', 'floor', 'cancelled', 'quota', 'concurrency', 'service', 'input', 'auth', 'unknown'] }, - 'Result Action': { action: ['copy', 'download', 'export', 'audit'] }, - 'Checkout Started': { surface: ['pricing', 'quota'], lang: ['en', 'ko', 'zh', 'ja'] }, - 'Tier Selected': { tier: ['free', 'pro', 'byok'], surface: ['pricing', 'controls'] }, -}; - -const eventSlugs = { - 'Funnel Progress': 'funnel-progress', - 'Input Started': 'input-started', - 'Rewrite Requested': 'rewrite-requested', - 'Rewrite Completed': 'rewrite-completed', - 'Rewrite Failed': 'rewrite-failed', - 'Result Action': 'result-action', - 'Checkout Started': 'checkout-started', - 'Tier Selected': 'tier-selected', -}; - -/** @param {unknown} value */ -function isPlainObject(value) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -/** - * Returns true only for a complete, exact allowlisted categorical event. - * @param {unknown} value - * @returns {value is {name: keyof typeof eventSchemas, data: Record}} - */ -export function validateFunnelEvent(value) { - if (!isPlainObject(value)) return false; - const candidate = /** @type {Record} */ (value); - if (Object.keys(candidate).length !== 2 || !Object.hasOwn(candidate, 'name') || !Object.hasOwn(candidate, 'data')) return false; - if (typeof candidate.name !== 'string' || !isPlainObject(candidate.data)) return false; - if (!Object.hasOwn(eventSchemas, candidate.name)) return false; - const schema = eventSchemas[candidate.name]; - const keys = Object.keys(schema); - if (Object.keys(candidate.data).length !== keys.length || !keys.every((key) => Object.hasOwn(candidate.data, key))) return false; - return keys.every((key) => typeof candidate.data[key] === 'string' && schema[key].includes(candidate.data[key])); -} - -/** - * @param {{name: keyof typeof eventSchemas, data: Record}} event - * @param {Date|number|string} now - */ -export function funnelCounterKey(event, now) { - if (!validateFunnelEvent(event)) throw new TypeError('invalid funnel event'); - const date = new Date(now); - if (Number.isNaN(date.getTime())) throw new TypeError('invalid counter time'); - const day = date.toISOString().slice(0, 10); - const dimensions = Object.keys(event.data).sort().map((key) => `${key}=${event.data[key]}`); - return ['patina', 'funnel', FUNNEL_SCHEMA_VERSION, day, eventSlugs[event.name], ...dimensions].join(':'); -} diff --git a/tests/e2e/dev-server.test.js b/tests/e2e/dev-server.test.js index 536a8806..840150b9 100644 --- a/tests/e2e/dev-server.test.js +++ b/tests/e2e/dev-server.test.js @@ -19,20 +19,6 @@ const ROOT = fileURLToPath(new URL('../../', import.meta.url)); const ORIGINAL = 'It is important to note that we retain 12 audit logs.'; const REWRITTEN = 'We retain 12 audit logs.'; -test('preview validates same-origin funnel milestones without production storage', async (t) => { - const base = await startPreview(t); - const event = { name: 'Funnel Progress', data: { lang: 'ko', channel: 'community', campaign: 'multilingual-20260907', stage: 'arrival' } }; - const send = (body, origin = new URL(base).origin) => fetch(new URL('/api/funnel', base), { - method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin, 'Sec-Fetch-Site': 'same-origin' }, - body: JSON.stringify(body), - }); - const accepted = await send(event); - assert.equal(accepted.status, 204); - assert.equal(accepted.headers.get('x-patina-mock'), '1'); - assert.equal((await send({ ...event, data: { ...event.data, text: 'must not be accepted' } })).status, 400); - assert.equal((await send(event, 'https://example.invalid')).status, 403); -}); - async function startPreview(t, { root = ROOT, env = {} } = {}) { const child = spawn(process.execPath, [path.join(root, 'scripts/dev-server.mjs'), '--host', '127.0.0.1', '--port', '0'], { cwd: root, @@ -236,8 +222,6 @@ test('public-root symlinks cannot expose private or hidden files', { timeout: 15 try { await mkdir(path.join(root, 'scripts')); await cp(path.join(ROOT, 'scripts/dev-server.mjs'), path.join(root, 'scripts/dev-server.mjs')); - await mkdir(path.join(root, 'api')); - await cp(path.join(ROOT, 'api/funnel.js'), path.join(root, 'api/funnel.js')); await cp(path.join(ROOT, 'playground'), path.join(root, 'playground'), { recursive: true }); await symlink(path.join(ROOT, 'src'), path.join(root, 'src'), 'dir'); await mkdir(path.join(root, 'playground-private')); diff --git a/tests/unit/funnel-analytics.test.js b/tests/unit/funnel-analytics.test.js deleted file mode 100644 index e3542c41..00000000 --- a/tests/unit/funnel-analytics.test.js +++ /dev/null @@ -1,285 +0,0 @@ -// @ts-check -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { FUNNEL_PROGRESS_SCHEMA, funnelCounterKey, validateFunnelEvent } from '../../src/funnel-analytics.js'; -import { createFunnelAggregateStore, createFunnelApiHandler } from '../../api/funnel.js'; - -const validEvent = { name: 'Rewrite Completed', data: { surface: 'hero', lang: 'en', tier: 'free', mode: 'first', inputBucket: '100-499', latencyBucket: '5-10s', mpsBand: '80-89', fidelityBand: '90-100' } }; - -function request({ method = 'POST', body = JSON.stringify(validEvent), headers = {} } = {}) { - return { - method, - body, - headers: { - origin: 'https://app.example.test', - 'x-forwarded-proto': 'https', - 'x-forwarded-host': 'app.example.test', - 'sec-fetch-site': 'same-origin', - 'content-type': 'application/json', - ...headers, - }, - }; -} - -function response() { - const headers = new Map(); - const chunks = []; - return { - statusCode: 200, - setHeader(name, value) { headers.set(String(name).toLowerCase(), String(value)); }, - end(value) { if (value !== undefined) chunks.push(String(value)); this.ended = true; }, - headers, - chunks, - }; -} - -test('funnel schema accepts every complete categorical event and rejects incomplete or extra shapes', () => { - const events = [ - { name: 'Funnel Progress', data: { lang: 'ko', channel: 'github', campaign: 'multilingual-20260907', stage: 'arrival' } }, - { name: 'Input Started', data: { surface: 'chat', lang: 'ko' } }, - { name: 'Rewrite Requested', data: { surface: 'hero', lang: 'zh', tier: 'byok', mode: 'refine', inputBucket: '2000+' } }, - validEvent, - { name: 'Rewrite Failed', data: { surface: 'chat', lang: 'ja', tier: 'pro', mode: 'first', inputBucket: '0-99', latencyBucket: '30s+', outcome: 'concurrency' } }, - { name: 'Result Action', data: { action: 'audit' } }, - { name: 'Checkout Started', data: { surface: 'quota', lang: 'en' } }, - { name: 'Tier Selected', data: { tier: 'free', surface: 'controls' } }, - ]; - for (const event of events) assert.equal(validateFunnelEvent(event), true, event.name); - assert.equal(validateFunnelEvent({ name: 'Input Started', data: { surface: 'hero' } }), false); - assert.equal(validateFunnelEvent({ name: 'Input Started', data: { surface: 'hero', lang: 'en', extra: 'x' } }), false); - assert.equal(validateFunnelEvent({ name: 'Unknown', data: {} }), false); - assert.equal(validateFunnelEvent({ name: 'toString', data: {} }), false); - assert.equal(validateFunnelEvent({ name: 'Result Action', data: { action: 'share' } }), false); - for (const outcome of ['preflight', 'stream', 'number-safety', 'scoring', 'floor', 'cancelled', 'quota', 'concurrency', 'service', 'input', 'auth', 'unknown']) { - assert.equal(validateFunnelEvent({ name: 'Rewrite Failed', data: { surface: 'hero', lang: 'en', tier: 'free', mode: 'first', inputBucket: '0-99', latencyBucket: '<5s', outcome } }), true, outcome); - } -}); - -test('funnel schema rejects all free-form and contextual fields', () => { - for (const field of ['text', 'content', 'input', 'ip', 'session', 'user', 'url', 'referrer', 'utm', 'model', 'provider', 'hash', 'receipt', 'email', 'token']) { - assert.equal(validateFunnelEvent({ ...validEvent, data: { ...validEvent.data, [field]: 'customer-or-context-data' } }), false, field); - } - assert.equal(validateFunnelEvent({ name: 'Rewrite Requested', data: { ...validEvent.data, inputBucket: 'arbitrary customer text' } }), false); -}); - -test('funnel key is UTC-day deterministic, sorted, and contains no prohibited data', () => { - const event = /** @type {const} */ ({ name: 'Input Started', data: { lang: 'en', surface: 'hero' } }); - const key = funnelCounterKey(event, '2026-08-18T00:30:00+09:00'); - assert.equal(key, 'patina:funnel:v1:2026-08-17:input-started:lang=en:surface=hero'); - for (const forbidden of ['ip', 'session', 'user', 'url', 'referrer', 'utm', 'model', 'provider', 'hash', 'receipt']) assert.doesNotMatch(key, new RegExp(forbidden, 'i')); - assert.throws(() => funnelCounterKey({ name: 'Input Started', data: { surface: 'hero', lang: 'en', url: 'https://example.test' } }, new Date()), /invalid funnel event/); -}); - -test('funnel endpoint rejects invalid method, body, origin, fetch-site, and oversized requests', async () => { - const handler = createFunnelApiHandler({ aggregateStore: { increment() { throw new Error('must not store'); } } }); - for (const [input, expected] of [ - [request({ method: 'GET' }), 405], - [request({ body: '{' }), 400], - [request({ headers: { origin: 'https://other.example.test' } }), 403], - [request({ headers: { 'sec-fetch-site': 'cross-site' } }), 403], - [request({ body: 'x'.repeat(4097) }), 413], - ]) { - const res = response(); - await handler(input, res); - assert.equal(res.statusCode, expected); - } -}); - -test('production funnel intake accepts only configured deployment hosts', async () => { - const calls = []; - const env = { VERCEL: '1', VERCEL_URL: 'app.example.test' }; - const handler = createFunnelApiHandler({ - env, - aggregateStore: { increment: async () => { calls.push('stored'); } }, - }); - const allowed = response(); - await handler(request(), allowed); - assert.equal(allowed.statusCode, 204); - assert.deepEqual(calls, ['stored']); - - const rejected = response(); - await handler(request({ headers: { - origin: 'https://preview.example.test', - 'x-forwarded-host': 'preview.example.test', - } }), rejected); - assert.equal(rejected.statusCode, 403); - assert.deepEqual(calls, ['stored']); -}); - -test('aggregate store enforces one bounded UTC-day spend counter', async () => { - const calls = []; - const env = { - PATINA_OBSERVABILITY_REST_API_URL: 'https://example.upstash.io/', - PATINA_OBSERVABILITY_REST_API_TOKEN: 'secret', - PATINA_FUNNEL_EVENTS_PER_DAY: '123', - }; - const store = createFunnelAggregateStore(env, async (...args) => { - calls.push(args); - return /** @type {any} */ ({ ok: true, json: async () => ({ result: 1 }) }); - }); - assert.ok(store); - const key = 'patina:funnel:v1:2026-08-18:input-started:lang=en:surface=hero'; - await store.increment(key, { ttlSeconds: 60 }); - const command = JSON.parse(calls[0][1].body); - assert.equal(command[0], 'EVAL'); - assert.equal(typeof command[1], 'string'); - assert.deepEqual(command.slice(2), ['2', 'patina:funnel:v1:2026-08-18:budget', key, '1', '60000', '123']); - - const capped = createFunnelAggregateStore(env, async () => ( - /** @type {any} */ ({ ok: true, json: async () => ({ result: -1 }) }) - )); - assert.ok(capped); - await assert.rejects( - capped.increment(key, { ttlSeconds: 60 }), - (error) => /** @type {any} */ (error)?.code === 'FUNNEL_DAILY_LIMIT', - ); -}); - -test('funnel endpoint fails closed on unavailable storage and increments exactly one aggregate counter', async () => { - const unavailable = createFunnelApiHandler({ aggregateStore: null }); - const unavailableResponse = response(); - await unavailable(request(), unavailableResponse); - assert.equal(unavailableResponse.statusCode, 503); - - const calls = []; - const handler = createFunnelApiHandler({ - now: () => new Date('2026-08-18T12:00:00.000Z'), - aggregateStore: { increment: async (key, options) => { calls.push({ key, options }); } }, - }); - const res = response(); - await handler(request(), res); - assert.equal(res.statusCode, 204); - assert.equal(res.chunks.length, 0); - assert.deepEqual(calls, [{ - key: 'patina:funnel:v1:2026-08-18:rewrite-completed:fidelityBand=90-100:inputBucket=100-499:lang=en:latencyBucket=5-10s:mode=first:mpsBand=80-89:surface=hero:tier=free', - options: { ttlSeconds: 35 * 24 * 60 * 60 }, - }]); -}); - -test('funnel endpoint returns 503 when aggregate storage fails', async () => { - const handler = createFunnelApiHandler({ aggregateStore: { increment: async () => { throw new Error('offline'); } } }); - const res = response(); - await handler(request(), res); - assert.equal(res.statusCode, 503); - assert.equal(res.chunks.length, 0); - - const limited = createFunnelApiHandler({ - aggregateStore: { - increment: async () => { - const error = /** @type {Error & {code: string}} */ (new Error('limit')); - error.code = 'FUNNEL_DAILY_LIMIT'; - throw error; - }, - }, - }); - const limitedResponse = response(); - await limited(request(), limitedResponse); - assert.equal(limitedResponse.statusCode, 429); -}); - -const progressEvent = /** @type {const} */ ({ name: 'Funnel Progress', data: { - lang: 'ja', channel: 'community', campaign: 'multilingual-20260907', stage: 'reuse', -} }); - -test('milestone schema is exact, immutable and bounded independently of rewrite dimensions', () => { - assert.equal(Object.isFrozen(FUNNEL_PROGRESS_SCHEMA), true); - let cardinality = 1; - for (const [field, allowed] of Object.entries(FUNNEL_PROGRESS_SCHEMA)) { - assert.equal(Object.isFrozen(allowed), true); - cardinality *= allowed.length; - for (const invalid of ['', 'customer-123', 'https://private.test', 'sk_secret', 'user@example.test', 1, null, {}, []]) { - assert.equal(validateFunnelEvent({ ...progressEvent, data: { ...progressEvent.data, [field]: invalid } }), false); - } - const missing = { ...progressEvent.data }; - delete missing[field]; - assert.equal(validateFunnelEvent({ ...progressEvent, data: missing }), false); - } - assert.equal(cardinality, 120); - for (const field of ['utm_source', 'utm_campaign', 'utm_medium', 'utm_content', 'text', 'input', 'output', 'ip', 'id', 'session', 'pageId', 'user', 'url', 'referrer', 'token', 'key', 'hash', 'timestamp', 'surface', 'mode', 'tier']) { - assert.equal(validateFunnelEvent({ ...progressEvent, data: { ...progressEvent.data, [field]: 'private' } }), false, field); - assert.equal(validateFunnelEvent({ ...progressEvent, [field]: 'private' }), false, field); - } - // Detailed legacy events cannot receive attribution or change their key shape. - for (const field of ['channel', 'campaign', 'stage']) { - assert.equal(validateFunnelEvent({ ...validEvent, data: { ...validEvent.data, [field]: progressEvent.data[field] } }), false); - } -}); - -test('milestone intake stores only one categorical UTC-day counter, even with sensitive request headers', async () => { - const calls = []; - const handler = createFunnelApiHandler({ - now: () => '2026-09-07T00:00:00+09:00', - aggregateStore: { increment(key, options) { calls.push({ key, options }); } }, - }); - const res = response(); - await handler(request({ body: JSON.stringify(progressEvent), headers: { - 'x-forwarded-for': '192.0.2.42', cookie: 'session=private', - authorization: 'Bearer private-license', referer: 'https://private.test/draft?key=secret', - 'user-agent': 'fingerprint-canary', - } }), res); - assert.equal(res.statusCode, 204); - assert.deepEqual(calls, [{ - key: 'patina:funnel:v1:2026-09-06:funnel-progress:campaign=multilingual-20260907:channel=community:lang=ja:stage=reuse', - options: { ttlSeconds: 35 * 24 * 60 * 60 }, - }]); - assert.deepEqual(res.chunks, []); - assert.equal(res.headers.get('cache-control'), 'no-store'); - assert.doesNotMatch(JSON.stringify(calls), /192\.0\.2|private|secret|fingerprint|cookie|authorization|referer|https?:/); -}); - -test('invalid milestone payloads fail before storage and cannot grow aggregate cardinality', async () => { - let calls = 0; - const handler = createFunnelApiHandler({ aggregateStore: { increment() { calls += 1; } } }); - for (let i = 0; i < 200; i++) { - const res = response(); - await handler(request({ body: JSON.stringify({ ...progressEvent, data: { ...progressEvent.data, campaign: `customer-${i}` } }) }), res); - assert.equal(res.statusCode, 400); - } - for (const data of [{ ...progressEvent.data, utm_source: 'secret' }, { ...progressEvent.data, channel: 'https://private.test' }]) { - const res = response(); - await handler(request({ body: JSON.stringify({ ...progressEvent, data }) }), res); - assert.equal(res.statusCode, 400); - } - assert.equal(calls, 0); -}); - -test('milestones share the existing atomic daily budget, TTL and fail-closed storage behavior', async () => { - const env = { - PATINA_OBSERVABILITY_REST_API_URL: 'https://example.upstash.io', - PATINA_OBSERVABILITY_REST_API_TOKEN: 'fake-secret', - PATINA_FUNNEL_EVENTS_PER_DAY: '2', - }; - const commands = []; - let now = '2026-09-07T23:59:59Z'; - const handler = createFunnelApiHandler({ env, now: () => now, fetchImpl: async (_url, options) => { - commands.push(JSON.parse(/** @type {string} */ (options.body))); - return /** @type {any} */ ({ ok: true, json: async () => ({ result: commands.length > 2 ? -1 : 1 }) }); - } }); - for (const [event, status] of [[validEvent, 204], [progressEvent, 204], [progressEvent, 429]]) { - const res = response(); - await handler(request({ body: JSON.stringify(event) }), res); - assert.equal(res.statusCode, status); - assert.deepEqual(res.chunks, []); - } - for (const command of commands) { - assert.equal(command[0], 'EVAL'); - assert.deepEqual(command.slice(2, 4), ['2', 'patina:funnel:v1:2026-09-07:budget']); - assert.deepEqual(command.slice(5), ['1', String(35 * 24 * 60 * 60 * 1000), '2']); - } - now = '2026-09-08T00:00:00Z'; - await handler(request({ body: JSON.stringify(progressEvent) }), response()); - assert.equal(commands[3][3], 'patina:funnel:v1:2026-09-08:budget'); - for (const fetchImpl of [ - async () => { throw new Error('private transport context'); }, - async () => (/** @type {any} */ ({ ok: false })), - async () => (/** @type {any} */ ({ ok: true, json: async () => ({ result: '1' }) })), - ]) { - const res = response(); - await createFunnelApiHandler({ env, fetchImpl })(request({ body: JSON.stringify(progressEvent) }), res); - assert.equal(res.statusCode, 503); - assert.deepEqual(res.chunks, []); - } - const res = response(); - await createFunnelApiHandler({ aggregateStore: null })(request({ body: JSON.stringify(progressEvent) }), res); - assert.equal(res.statusCode, 503); -}); diff --git a/tests/unit/playground-analytics.test.js b/tests/unit/playground-analytics.test.js deleted file mode 100644 index b5d869af..00000000 --- a/tests/unit/playground-analytics.test.js +++ /dev/null @@ -1,312 +0,0 @@ -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import vm from 'node:vm'; -import { FUNNEL_PROGRESS_SCHEMA, funnelCounterKey, validateFunnelEvent } from '../../src/funnel-analytics.js'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); -const analytics = readFileSync(join(root, 'playground', 'analytics.js'), 'utf8'); -const index = readFileSync(join(root, 'playground', 'index.html'), 'utf8'); -const controller = readFileSync(join(root, 'playground', 'chatgpt.js'), 'utf8'); - -function adapter({ fetch, location, ...rest } = {}) { - const window = { fetch, location, URLSearchParams: globalThis.URLSearchParams, ...rest }; - vm.runInNewContext(analytics, { window, Set, Object, Array }); - return window; -} - -test('analytics adapter sends the complete allowlisted event body with exact request options', () => { - const calls = []; - const window = adapter({ - fetch: (...args) => { - calls.push(args); - return Promise.resolve(); - }, - }); - - window.patinaTrack('Rewrite Completed', { - surface: 'hero', lang: 'ko', tier: 'pro', mode: 'first', inputBucket: '500-1999', - latencyBucket: '5-10s', mpsBand: '90-100', fidelityBand: '80-89', - }); - - assert.deepEqual(JSON.parse(calls[0][1].body), { - name: 'Rewrite Completed', - data: { - surface: 'hero', lang: 'ko', tier: 'pro', mode: 'first', inputBucket: '500-1999', - latencyBucket: '5-10s', mpsBand: '90-100', fidelityBand: '80-89', - }, - }); - const { body: _body, ...requestOptions } = calls[0][1]; - assert.deepEqual(JSON.parse(JSON.stringify(requestOptions)), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'omit', - referrerPolicy: 'no-referrer', - keepalive: true, - }); -}); - -const completed = { - surface: 'hero', lang: 'en', tier: 'free', mode: 'first', inputBucket: '100-499', - latencyBucket: '<5s', mpsBand: '90-100', fidelityBand: '90-100', -}; - -function recordingAdapter(search = '') { - const events = []; - const window = adapter({ location: { search }, fetch: (_url, options) => { - events.push(JSON.parse(options.body)); - return Promise.resolve(); - } }); - return { window, events, progress: () => events.filter((event) => event.name === 'Funnel Progress') }; -} - -test('funnel waits for actual UI readiness and counts arrival once in the initialized language', () => { - const { window, progress } = recordingAdapter('?lang=ko&utm_source=github&utm_campaign=multilingual-20260907'); - assert.deepEqual(progress(), []); - window.patinaFunnelReady('not-a-language'); - window.patinaFunnelReady({ lang: 'ko' }); - window.patinaTrack('Rewrite Completed', completed); - assert.deepEqual(progress(), []); - // The URL language is not evidence that the UI initialized in that language. - window.patinaFunnelReady('ja'); - window.patinaFunnelReady('en'); - assert.deepEqual(progress(), [{ name: 'Funnel Progress', data: { - lang: 'ja', channel: 'github', campaign: 'multilingual-20260907', stage: 'arrival', - } }]); -}); - -test('first success and second-success reuse are once per loaded page, across modes and language changes', () => { - const { window, events, progress } = recordingAdapter('?utm_source=community&utm_campaign=multilingual-20260907'); - window.patinaFunnelReady('ko'); - window.patinaTrack('Rewrite Requested', { surface: 'hero', lang: 'ko', tier: 'free', mode: 'first', inputBucket: '100-499' }); - window.patinaTrack('Rewrite Failed', { surface: 'hero', lang: 'ko', tier: 'free', mode: 'first', inputBucket: '100-499', latencyBucket: '<5s', outcome: 'floor' }); - window.patinaTrack('Result Action', { action: 'copy' }); - for (const bad of [{ ...completed, text: 'private draft' }, { ...completed, surface: 'pricing' }, { ...completed, lang: 'invalid' }]) { - window.patinaTrack('Rewrite Completed', bad); - } - // These remain legal legacy categories, but cannot establish success. - window.patinaTrack('Rewrite Completed', { ...completed, mpsBand: 'failed' }); - window.patinaTrack('Rewrite Completed', { ...completed, fidelityBand: 'failed' }); - assert.deepEqual(progress().map((event) => event.data.stage), ['arrival']); - window.patinaTrack('Rewrite Completed', completed); - assert.deepEqual(progress().map((event) => event.data.stage), ['arrival', 'first-success']); - window.location.search = '?utm_source=social&utm_campaign=other'; - window.patinaFunnelReady('en'); - for (let i = 0; i < 100; i++) { - window.patinaTrack('Rewrite Completed', { ...completed, lang: 'zh', mode: i % 2 ? 'verify' : 'refine' }); - } - assert.deepEqual(progress().map((event) => event.data), [ - { lang: 'ko', channel: 'community', campaign: 'multilingual-20260907', stage: 'arrival' }, - { lang: 'ko', channel: 'community', campaign: 'multilingual-20260907', stage: 'first-success' }, - { lang: 'ko', channel: 'community', campaign: 'multilingual-20260907', stage: 'reuse' }, - ]); - assert.equal(events.filter((event) => event.name === 'Rewrite Completed').length, 103); - assert.ok(events.every(validateFunnelEvent)); - const nextPage = recordingAdapter(); - nextPage.window.patinaFunnelReady('ko'); - nextPage.window.patinaTrack('Rewrite Completed', completed); - assert.deepEqual(nextPage.progress().map((event) => event.data.stage), ['arrival', 'first-success']); -}); - -test('all browser milestone combinations match the server contract, with exactly 120 daily keys', () => { - const keys = new Set(); - for (const lang of FUNNEL_PROGRESS_SCHEMA.lang) { - for (const channel of FUNNEL_PROGRESS_SCHEMA.channel) { - for (const campaign of FUNNEL_PROGRESS_SCHEMA.campaign) { - const { window, progress } = recordingAdapter(`?utm_source=${channel}&utm_campaign=${campaign}`); - window.patinaFunnelReady(lang); - window.patinaTrack('Rewrite Completed', completed); - window.patinaTrack('Rewrite Completed', completed); - assert.equal(progress().length, 3); - for (const event of progress()) { - assert.equal(validateFunnelEvent(event), true); - assert.deepEqual(event.data, { lang, channel, campaign, stage: event.data.stage }); - keys.add(funnelCounterKey(event, '2026-09-07')); - } - } - } - } - assert.equal(keys.size, 120); -}); - -test('attribution only accepts exact finite labels; missing, duplicated, large and arbitrary UTMs collapse', () => { - const searches = [ - '', '?utm_source=Github&utm_campaign=MULTILINGUAL-20260907', - '?utm_source=github&%75tm_source=social&utm_campaign=multilingual-20260907&utm_campaign=multilingual-20260907', - `?utm_source=github&utm_campaign=multilingual-20260907&text=${'x'.repeat(4096)}`, - '?utm_source=github%00&utm_campaign=multilingual-20260907%20', - '?utm_source=%FF&utm_campaign=%FF', - ]; - for (let i = 0; i < 200; i++) searches.push(`?utm_source=customer-${i}&utm_campaign=private-${i}`); - const keys = new Set(); - for (const search of searches) { - const { window, progress } = recordingAdapter(search); - window.patinaFunnelReady('en'); - const event = progress()[0]; - assert.deepEqual(event.data, { lang: 'en', channel: 'unattributed', campaign: 'none', stage: 'arrival' }); - keys.add(funnelCounterKey(event, '2026-09-07')); - } - assert.equal(keys.size, 1); - for (const [search, channel, campaign] of [ - ['?utm_source=blog&utm_campaign=secret', 'blog', 'none'], - ['?utm_source=secret&utm_campaign=multilingual-20260907', 'unattributed', 'multilingual-20260907'], - ['?utm_source=%67ithub&utm_campaign=multilingual-20260907', 'github', 'multilingual-20260907'], - ]) { - const { window, progress } = recordingAdapter(search); - window.patinaFunnelReady('en'); - assert.deepEqual(progress()[0].data, { lang: 'en', channel, campaign, stage: 'arrival' }); - } -}); - -test('privacy canaries never enter milestone bodies and browser identity/context stores are never touched', () => { - const { window, events, progress } = recordingAdapter('?utm_source=social&utm_campaign=multilingual-20260907&utm_medium=sk_live_secret&utm_content=private-draft&utm_term=user-123&ref=https%3A%2F%2Fprivate.test&key=credential'); - const forbidden = () => { throw new Error('must not access identity or page content'); }; - for (const name of ['localStorage', 'sessionStorage', 'document', 'navigator', 'crypto']) { - Object.defineProperty(window, name, { get: forbidden }); - } - for (const name of ['href', 'hash', 'host', 'pathname']) Object.defineProperty(window.location, name, { get: forbidden }); - window.patinaFunnelReady('en'); - window.patinaTrack('Rewrite Completed', completed); - window.patinaTrack('Rewrite Completed', completed); - window.patinaTrack('Funnel Progress', { lang: 'en', channel: 'github', campaign: 'none', stage: 'reuse' }); - assert.equal(progress().length, 3); - assert.ok(events.every(validateFunnelEvent)); - assert.doesNotMatch(JSON.stringify(events), /sk_live_secret|private-draft|user-123|private\.test|credential|utm_|referrer|session|cookie|https?:/); -}); - -test('readiness and milestones isolate blocked location, missing fetch, thrown fetch and rejected fetch without retry', async () => { - for (const mode of ['missing', 'throws', 'rejects', 'limited', 'offline']) { - let calls = 0; - const fetch = mode === 'missing' ? undefined : () => { - calls += 1; - if (mode === 'throws') throw new Error('unavailable'); - if (mode === 'rejects') return Promise.reject(new Error('offline')); - return Promise.resolve({ ok: false, status: mode === 'limited' ? 429 : 503 }); - }; - const window = adapter({ fetch }); - Object.defineProperty(window, 'location', { get() { throw new Error('blocked'); } }); - assert.doesNotThrow(() => window.patinaFunnelReady('en')); - window.patinaFunnelReady('en'); - for (let i = 0; i < 3; i++) assert.doesNotThrow(() => window.patinaTrack('Rewrite Completed', completed)); - await Promise.resolve(); - assert.equal(calls, mode === 'missing' ? 0 : 6); - } -}); - -test('analytics adapter sends failure categories through the same fixed envelope', () => { - const calls = []; - const window = adapter({ - fetch: (...args) => { - calls.push(args); - return Promise.resolve(); - }, - }); - - window.patinaTrack('Rewrite Failed', { - surface: 'hero', lang: 'en', tier: 'free', mode: 'first', inputBucket: '0-99', - latencyBucket: '<5s', outcome: 'concurrency', - }); - - assert.deepEqual(JSON.parse(JSON.stringify(calls)), [[ - '/api/funnel', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{"name":"Rewrite Failed","data":{"surface":"hero","lang":"en","tier":"free","mode":"first","inputBucket":"0-99","latencyBucket":"<5s","outcome":"concurrency"}}', - credentials: 'omit', - referrerPolicy: 'no-referrer', - keepalive: true, - }, - ]]); -}); - -test('analytics adapter rejects unknown, incomplete, extra, invalid, and free-form calls', () => { - const calls = []; - const window = adapter({ - fetch: (...args) => { - calls.push(args); - return Promise.resolve(); - }, - }); - - window.patinaTrack('Purchase Completed', { tier: 'pro' }); - window.patinaTrack('Rewrite Requested', { surface: 'hero', lang: 'ko', tier: 'pro', mode: 'first' }); - window.patinaTrack('Rewrite Requested', { surface: 'hero', lang: 'ko', tier: 'pro', mode: 'first', inputBucket: '500-1999', text: 'customer draft' }); - window.patinaTrack('Rewrite Requested', { text: 'customer draft', url: 'https://example.test', apiKey: 'sk_live_secret' }); - window.patinaTrack('Rewrite Requested', { surface: 'hero', lang: 'Korean prose', tier: 'pro', mode: 'first', inputBucket: '500-1999' }); - - assert.deepEqual(calls, []); -}); - -test('analytics adapter isolates fetch exceptions', () => { - const fetchWindow = adapter({ - fetch: () => { throw new Error('fetch unavailable'); }, - }); - const rejectedFetchWindow = adapter({ - fetch: () => Promise.reject(new Error('network unavailable')), - }); - - assert.doesNotThrow(() => fetchWindow.patinaTrack('Input Started', { surface: 'hero', lang: 'en' })); - assert.doesNotThrow(() => rejectedFetchWindow.patinaTrack('Input Started', { surface: 'hero', lang: 'en' })); -}); - -test('analytics adapter allows each added Rewrite Failed outcome', () => { - const calls = []; - const window = adapter({ - fetch: (...args) => { - calls.push(args); - return Promise.resolve(); - }, - }); - - for (const outcome of ['quota', 'concurrency', 'service', 'input', 'auth']) { - window.patinaTrack('Rewrite Failed', { - surface: 'hero', lang: 'en', tier: 'free', mode: 'first', inputBucket: '0-99', - latencyBucket: '<5s', outcome, - }); - } - - assert.equal(calls.length, 5); -}); - -test('analytics body contains no prohibited fields or free-form values', () => { - const calls = []; - const window = adapter({ - fetch: (...args) => { - calls.push(args); - return Promise.resolve(); - }, - }); - - window.patinaTrack('Rewrite Requested', { - surface: 'hero', lang: 'en', tier: 'pro', mode: 'first', inputBucket: '500-1999', - }); - - const body = calls[0][1].body; - assert.doesNotMatch(body, /\b(?:url|query|referrer|user|session|utm|model|provider|hash|receipt|text|key|license)\b/i); - assert.doesNotMatch(body, /customer draft|sk_live_secret|https?:/); -}); - -test('only the application-owned analytics script loads before the application module', () => { - const adapterScript = index.indexOf(''); - const appScript = index.indexOf(''); - assert.ok(adapterScript >= 0 && adapterScript < appScript); - assert.doesNotMatch(index, /_vercel\/insights|@vercel\/analytics/i); -}); - -test('controller wires the complete aggregate funnel without Purchase Completed or telemetry payloads', () => { - for (const event of ['Input Started', 'Rewrite Requested', 'Rewrite Completed', 'Rewrite Failed', 'Result Action', 'Checkout Started', 'Tier Selected']) { - assert.match(controller, new RegExp(`track\\('${event}'`)); - } - assert.doesNotMatch(controller, /track\('Purchase Completed'/); - assert.match(controller, /function inputBucket\(length\)/); - assert.match(controller, /function latencyBucket\(startedAt\)/); - assert.match(controller, /function scoreBand\(score\)/); - assert.match(controller, /track\('Result Action', \{ action: 'audit' \}\)/); - assert.match(controller, /track\('Checkout Started', \{ surface: 'pricing', lang: els\.lang\.value \}\)/); - assert.match(controller, /track\('Checkout Started', \{ surface: 'quota', lang: els\.lang\.value \}\)/); - const trackingLines = controller.split('\n').filter((line) => line.includes('track(')).join('\n'); - assert.doesNotMatch(trackingLines, /\b(?:text|receipt|apiKey|license|provider|model|capturedUtm)\s*:/); -}); diff --git a/tests/unit/playground-experience.test.js b/tests/unit/playground-experience.test.js index 0ec5bbea..0f2aabd9 100644 --- a/tests/unit/playground-experience.test.js +++ b/tests/unit/playground-experience.test.js @@ -132,7 +132,7 @@ function documentFixture() { return document; } -function app({ response, storage = new Map(), languages = [], language, search = '', funnel } = {}) { +function app({ response, storage = new Map(), languages = [], language, search = '', funnel, track } = {}) { const document = documentFixture(); const calls = []; const reviews = []; @@ -141,6 +141,7 @@ function app({ response, storage = new Map(), languages = [], language, search = navigator: { languages, language }, location: { search }, patinaFunnelReady: funnel, + patinaTrack: track, createEditReview: (options) => { const pending = createEditReview({ ...options, document }); reviews.push(pending); @@ -1025,16 +1026,15 @@ test('optional controls open for credentials, close with Escape, and Free restor }); -test('funnel arrival runs once after locale/UI initialization and never blocks first use', async () => { - const arrivals = []; - const a = app({ languages: ['zh-CN'], funnel: (lang) => arrivals.push(lang) }); - assert.deepEqual(arrivals, ['zh']); - change(a, 'lang', 'ja'); +test('the playground calls no analytics hook during initialization, input, or a rewrite', async () => { + const hooks = []; + const a = app({ languages: ['zh-CN'], funnel: (lang) => hooks.push(['ready', lang]), track: (name) => hooks.push(['track', name]) }); + type(a, 'hero-input', 'Source 70%'); + change(a, 'tier', 'byok'); + change(a, 'tier', 'free'); await a.ui.submit('English source 70%'); - assert.deepEqual(arrivals, ['zh']); - const b = app({ languages: ['ko-KR'], funnel: () => { throw new Error('analytics unavailable'); } }); - type(b, 'hero-input', 'Source 70%'); - assert.equal(b.get('hero-send').disabled, false); + await a.settle(); + assert.deepEqual(hooks, []); }); @@ -1057,17 +1057,14 @@ test('optional settings for a new hero source do not mutate the preceding conver for (const lang of ['ko', 'en', 'zh', 'ja']) { - test(`${lang}: allowlisted URL locale overrides the browser and initializes the funnel`, async () => { - const arrivals = []; - const a = app({ search: `?lang=${lang}&utm_source=github`, languages: ['ja-JP', 'ko-KR'], funnel: (value) => arrivals.push(value) }); + test(`${lang}: allowlisted URL locale overrides the browser`, async () => { + const a = app({ search: `?lang=${lang}&utm_source=github`, languages: ['ja-JP', 'ko-KR'] }); assert.equal(a.get('lang').value, lang); assert.equal(a.document.documentElement.lang, lang); - assert.deepEqual(arrivals, [lang]); assert.equal(a.get('example-choice').value, `${lang}-fixture-0`); change(a, 'lang', 'ko'); await a.ui.submit('English source 70%'); assert.equal(a.calls[0].body.lang, 'ko', 'manual selection survives both arrival locale and source detection'); - assert.deepEqual(arrivals, [lang]); }); } diff --git a/tests/unit/web-deploy-invariants.test.js b/tests/unit/web-deploy-invariants.test.js index 0cfa6e63..89875abe 100644 --- a/tests/unit/web-deploy-invariants.test.js +++ b/tests/unit/web-deploy-invariants.test.js @@ -60,7 +60,6 @@ test('vercel.json serves only the playground as its static output', () => { 'chatgpt.js', 'chatgpt.css', 'rewrite-client.js', - 'analytics.js', 'launch-config.js', ];