@getvitops/astro 8.3.0-alpha.4 → 8.3.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/config-adapters.ts","../../src/routes/conversion.ts"],"mappings":";;;;;;;;;;;;UA6BiB;EACf;IAAiB,OAAO;IAAyB;;EACjD;IAAQ;;;;;;;;;;;;;;;;;;;;;wBAqBM,wBACd,QAAQ,wBACR;;;;;;;;;;wBAoBc,gBAAgB,QAAQ,QAAQ,kBAAkB;;;;;;;;wBAuBlD,YAAY,QAAQ,SAAS;;;;;;;;;;wBAa7B,kBACd,QAAQ,SACP,KAAK;;;iBCVS;EACf,SAAS;EACT;;;;;;;iBAQe;EACf,IAAI,YAAY,aAAa;;iBAkBd;;EAEf,SAAS;;;;;;;;;EAST,WAAW,oBAAoB;;;;;;;;;;;;EAY/B;IACE,QAAQ;IACR,eAAe,oBAAoB;IACnC,eAAe;IACf;;;;;;;;;;;;;;;;EAgBF;IACE,QAAQ,KAAK;IACb,eAAe,oBAAoB;IACnC,eAAe;;;;;;EAMjB,YAAY,MAAM;;;EAGlB,YAAY,QAAQ;;EAEpB;;;;;;;EAOA;;;;;;EAMA;;;;;;;;;;EAUA;;;;;;;;EAQA;;;;;;;;EAQA;;;;;;;;;;;EAWA;;;;;;;EAOA;IAAc;IAAmB,QAAQ;;;;;;;;EAOzC;IACE;;;IAGA;;;;;;;;;IASA;;;;;;;iBAQa,yBAAyB,QAAQ,kBAAkB;EAClE,MAAM;EACN,YAAY;;;;;;;;;;;;;;;wBA2GE,sBAAsB,SAAS,4BAyB/B,SAAA,UAAqB,6BAA2B,QAAQ"}
1
+ {"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/config-adapters.ts","../../src/routes/conversion.ts"],"mappings":";;;;;;;;;;;;UA6BiB;EACf;IAAiB,OAAO;IAAyB;;EACjD;IAAQ;;;;;;;;;;;;;;;;;;;;;wBAqBM,wBACd,QAAQ,wBACR;;;;;;;;;;wBAoBc,gBAAgB,QAAQ,QAAQ,kBAAkB;;;;;;;;wBAuBlD,YAAY,QAAQ,SAAS;;;;;;;;;;wBAa7B,kBACd,QAAQ,SACP,KAAK;;;iBCVS;EACf,SAAS;EACT;;;;;;;iBAQe;EACf,IAAI,YAAY,aAAa;;iBAkBd;;EAEf,SAAS;;;;;;;;;EAST,WAAW,oBAAoB;;;;;;;;;;;;EAY/B;IACE,QAAQ;IACR,eAAe,oBAAoB;IACnC,eAAe;IACf;;;;;;;;;;;;;;;;EAgBF;IACE,QAAQ,KAAK;IACb,eAAe,oBAAoB;IACnC,eAAe;;;;;;EAMjB,YAAY,MAAM;;;EAGlB,YAAY,QAAQ;;EAEpB;;;;;;;EAOA;;;;;;EAMA;;;;;;;;;;EAUA;;;;;;;;EAQA;;;;;;;;EAQA;;;;;;;;;;;EAWA;;;;;;;EAOA;IAAc;IAAmB,QAAQ;;;;;;;;EAOzC;IACE;;;IAGA;;;;;;;;;IASA;;;;;;;iBAQa,yBAAyB,QAAQ,kBAAkB;EAClE,MAAM;EACN,YAAY;;;;;;;;;;;;;;;wBA6GE,sBAAsB,SAAS,4BAyB/B,SAAA,UAAqB,6BAA2B,QAAQ"}
@@ -119,11 +119,14 @@ function googleClickId(tracking) {
119
119
  const CONTROL_FIELDS = /* @__PURE__ */ new Set([
120
120
  "event",
121
121
  "phone",
122
+ "phoneSource",
122
123
  "cf-turnstile-response"
123
124
  ]);
124
125
  /** The capture script sends `link.href` as-is: `tel:` + digits and the punctuation people type
125
126
  * (a space arrives as `%20`). Not free text. */
126
127
  const PHONE_RE = /^(?:tel:)?\+?[\d\s().%-]{3,40}$/i;
128
+ /** A `site.tracking.phoneNumbers` source key (`meta`, `default-paid`, …). Printed in a notification. */
129
+ const PHONE_SOURCE_RE = /^[a-z][a-z-]{1,19}$/;
127
130
  /** `mailto:` + an address, with an optional `?subject=…` query. A shape check, not deliverability. */
128
131
  const EMAIL_RE = /^(?:mailto:)?[^\s@<>"?]{1,64}@[^\s@<>"?]{1,255}(?:\?[^\s<>"]{0,256})?$/i;
129
132
  const DEFAULT_HONEYPOT_FIELDS = [
@@ -280,7 +283,10 @@ function createConversionRoute(options) {
280
283
  const excludedFields = turnstileFieldName ? [...honeypotFields, turnstileFieldName] : honeypotFields;
281
284
  const event = {
282
285
  type: isCall ? "call" : isEmailClick ? "email" : "form",
283
- ...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : isEmailClick ? { email: String(body["email"]).replace(/^mailto:/, "") } : { formData: toFormData(body, excludedFields) },
286
+ ...isCall ? {
287
+ phone: String(body["phone"]).replace(/^tel:/, ""),
288
+ ...typeof body["phoneSource"] === "string" && PHONE_SOURCE_RE.test(body["phoneSource"]) ? { phoneSource: body["phoneSource"] } : {}
289
+ } : isEmailClick ? { email: String(body["email"]).replace(/^mailto:/, "") } : { formData: toFormData(body, excludedFields) },
284
290
  tracking: parseTrackingCookie(request.headers.get("cookie")),
285
291
  userAgent: request.headers.get("user-agent") ?? void 0,
286
292
  ip,
@@ -1 +1 @@
1
- {"version":3,"file":"conversion.mjs","names":[],"sources":["../../src/routes/conversion.ts"],"sourcesContent":["/**\n * The conversion endpoint, as a factory.\n *\n * A site's contact form and its `tel:`/`mailto:` beacons all mean the same\n * thing — someone got in touch — and all want the same treatment: read the\n * attribution cookie off the request, build the event, notify whoever the\n * config says. That is the same three lines in every project, so it ships\n * here.\n *\n * What deliberately does **not** ship here is field-level validation and business\n * rules — a consumer's form has its own required fields and its own response\n * shape, and baking a guess at those in would make the factory something to\n * fight rather than mount. The handler takes the parsed body and an optional\n * `validate`; that part is the caller's. What **does** ship here is generic abuse\n * resistance (honeypot, origin, rate limit, body size): this is a public,\n * unauthenticated endpoint that sends email, so leaving it wide open makes it a\n * relay and a cost amplifier regardless of whose form is behind it.\n *\n * The path matters: the capture script beacons `tel:`/`mailto:` conversions at\n * `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),\n * and the integration warns at build if no route answers it.\n *\n * ```ts\n * // src/pages/api/track.ts\n * export const prerender = false;\n * import { env } from 'cloudflare:workers';\n * import { createConversionRoute } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: {\n * // A bare address is shorthand for `{ provider: 'cloudflare', to }`.\n * notifications: { email: 'sales@acme.ca' },\n * canonical: 'https://acme.ca',\n * organizationName: 'Acme',\n * },\n * binding: () => env.EMAIL,\n * // A no-JS <form method=\"post\"> submission never sees a JSON response body —\n * // it's a full navigation, so send it somewhere real.\n * redirectTo: '/thanks',\n * });\n * ```\n *\n * Cloudflare Turnstile is a separate, opt-in layer on top of the checks above —\n * pass `turnstile: { secretKey }` and a submission carrying a\n * `cf-turnstile-response` token gets it verified server-side via\n * `verifyTurnstileToken` (`@getvitops/utils/security`). The widget itself is the\n * consumer's own markup (`<script src=\"https://challenges.cloudflare.com/\n * turnstile/v0/api.js\" async defer></script>` + `<div class=\"cf-turnstile\"\n * data-sitekey=\"…\">` on their form) — there is no `FormRenderer` for this to hook\n * into generically, matching how the rest of the form is already hand-written.\n * `site.security.turnstile.siteKey` is what the generated privacy policy reads to\n * name Turnstile as a processor; wiring this option up is what makes that true\n * rather than an assertion nothing checks.\n *\n * Two things this example used to get wrong, both of which cost a consumer real\n * time. It read the binding off `locals.runtime.env`, which Astro removed in v6\n * — and because the resulting throw precedes the response, Astro re-reads the\n * request and reports a misleading \"Body has already been used\" instead of the\n * actual error. This package peers on `astro >= 7`, so that form could not work\n * for any supported consumer; `import { env } from 'cloudflare:workers'` is the\n * current one. It also called a `toNotifyContext(config)` helper that used to\n * not exist — `context` can now be built with `toNotifyContext(config)` (also\n * exported from this module), rather than every consumer hand-writing a\n * `NotifyContext` literal:\n *\n * ```ts\n * import config from '../../site.json' with { type: 'json' };\n * import { createConversionRoute, toNotifyContext, toCrmConfig } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: toNotifyContext(config),\n * crm: toCrmConfig(config) && { config: toCrmConfig(config)!, credentials: () => ({ ... }) },\n * binding: () => env.EMAIL,\n * redirectTo: '/thanks',\n * });\n * ```\n */\nimport { AD_PLATFORMS, normalizeConversionActionIds } from '@getvitops/utils/ads';\nimport type { AdPropertySetup } from '@getvitops/utils/ads';\nimport { deliverToCrm, describeCrmResult } from '@getvitops/utils/crm';\nimport type { CrmConfig, CrmCredentials, CrmResult } from '@getvitops/utils/crm';\nimport {\n describeGoogleAdsOfflineResult,\n type EmailBinding,\n notify,\n type NotifyContext,\n uploadClickConversion,\n type GoogleAdsCredentials,\n type GoogleAdsUploadResult,\n} from '@getvitops/utils/notify';\nimport { verifyTurnstileToken } from '@getvitops/utils/security';\nimport { parseTrackingCookie, readCookie } from '@getvitops/utils/tracking';\nimport type { ConversionEvent, TrackingData } from '@getvitops/utils/tracking';\nexport {\n resolveOrganizationName,\n toCrmConfig,\n toGoogleAdsConfig,\n toNotifyContext,\n} from './config-adapters.ts';\n\n/** The subset of Astro's `APIContext` this needs — typed structurally so the\n * factory does not pin an Astro version. */\nexport interface ConversionRequestContext {\n request: Request;\n locals?: unknown;\n}\n\n/**\n * Records a hit for `id` at time `at` (ms) and returns the ms since the previous\n * hit for that `id`, or `null` if this is the first. Pure interface — a store\n * decides nothing, it only remembers.\n */\nexport interface RateLimitStore {\n hit(id: string, at: number): Promise<number | null> | number | null;\n}\n\n/**\n * Per-isolate, in-memory default. Good enough for a single Worker instance\n * fielding occasional contact-form spam; not durable across a fleet, and not\n * shared with other routes. Inject a KV- or Durable-Object-backed `store` for\n * real cross-instance limiting — the interface is one method wide on purpose.\n */\nclass MemoryRateLimitStore implements RateLimitStore {\n private lastHit = new Map<string, number>();\n hit(id: string, at: number): number | null {\n const prev = this.lastHit.get(id);\n this.lastHit.set(id, at);\n return prev === undefined ? null : at - prev;\n }\n}\n\nexport interface ConversionRouteOptions {\n /** Where notifications go. Build it from your site config. */\n context: NotifyContext;\n /**\n * The Workers `send_email` binding for this request, e.g. `() => env.EMAIL`\n * with `env` imported from `cloudflare:workers`. Omit it in dev and the\n * notification is printed instead of sent.\n *\n * Not `(locals) => locals.runtime.env.EMAIL` — Astro removed `locals.runtime`\n * in v6, and this package peers on `>= 7`.\n */\n binding?: (locals: unknown) => EmailBinding | undefined;\n /**\n * Push the submission into a CRM (HubSpot, Zoho Bigin or Jobber) before notifying.\n * Credentials arrive as a function of `locals`, exactly like `binding` above\n * — the consumer reads them from `import { env } from 'cloudflare:workers'`.\n * Credentials are never config; that rule is absolute in this toolchain.\n *\n * A failed CRM write never fails the request and never blocks the honeypot/\n * Turnstile gates above it — those run first, so a bot never reaches the\n * CRM. The outcome is folded into the notification email's \"Delivery\"\n * section (via `describeCrmResult`) so a failed write is never silent.\n */\n crm?: {\n config: CrmConfig;\n credentials?: (locals: unknown) => CrmCredentials | undefined;\n fetch?: typeof fetch;\n timeoutMs?: number;\n };\n /**\n * Upload the conversion to Google Ads server-side instead of relying on the\n * visitor's browser to fire `gtag.js` — see `site.ads.google.reporting` and\n * `conversionActionId`. Credentials arrive as a function of `locals`, same\n * shape as `crm.credentials` and `binding` above.\n *\n * Only fires for an action whose `conversionActionId` is actually set (via\n * `toGoogleAdsConfig(config)`, or hand-built the same shape) **and** whose\n * event carries a Google click id (`gclid`/`gbraid`/`wbraid` on `_ac`) —\n * an organic conversion has nothing for the Ads API to attribute, so it's\n * silently skipped rather than reported as a failure. A failed upload never\n * fails the request, matching `crm` above; the outcome folds into the same\n * \"Delivery\" section via `describeGoogleAdsOfflineResult`.\n */\n googleAdsOffline?: {\n config: Pick<AdPropertySetup, 'accountId' | 'conversionActionId' | 'reporting'>;\n credentials?: (locals: unknown) => GoogleAdsCredentials | undefined;\n fetch?: typeof fetch;\n };\n /**\n * Reject a submission before anything is sent. Return a message to refuse.\n * This is where a consumer puts required-field checks.\n */\n validate?: (body: Record<string, unknown>) => string | null;\n /** Called with every channel outcome, delivered or not, plus the CRM\n * outcome when `crm` is configured. Defaults to console. */\n onResult?: (result: ConversionResult) => void;\n /** Injected in tests. */\n now?: () => number;\n /**\n * Where to send a no-JS `<form method=\"post\">` submission on success. Without\n * this, that visitor's browser navigates to the raw `{\"success\":true}` JSON\n * body as its next page — functional, but not a page anyone should see. Has\n * no effect on the beacon/fetch (JSON) path, which always gets a JSON response.\n */\n redirectTo?: string;\n /**\n * Where to send a refused no-JS submission (validation, spam checks, malformed\n * body). Falls back to `redirectTo` if omitted, and to the JSON error response\n * if neither is set.\n */\n errorRedirectTo?: string;\n /**\n * Field names treated as honeypots: a bot fills every input it finds, a person\n * never sees these (hidden by CSS in the form markup). A non-empty value is\n * silently treated as spam — the response looks identical to a real success,\n * denying the bot the signal it needs to know the trick worked, but nothing is\n * notified and the field never reaches `notify()`. Defaults to the same names\n * `render.ts`'s `OMIT_FIELDS` already knows about (`website`, `_honeypot`,\n * `honeypot`), so the two lists don't need to be kept in sync by hand.\n */\n honeypotFields?: string[];\n /**\n * Reject a submission whose `Content-Length` exceeds this many bytes. Default\n * 25_000 — generous for a contact form with no file upload, tiny for anything\n * meant to relay large payloads. The header is only a fast path: a chunked\n * request carries none, so the body is also read through a byte-capped\n * reader and refused the moment it passes the cap.\n */\n maxBodyBytes?: number;\n /**\n * Fall back to the first `X-Forwarded-For` hop as the client IP when\n * `CF-Connecting-IP` is absent. Default `false`: off Cloudflare the header is\n * whatever the client sent, so honouring it lets a script mint a fresh\n * rate-limit bucket per request. Turn on only behind a proxy that overwrites\n * it. With neither header trusted there is no IP, and rate limiting is skipped.\n */\n trustForwardedFor?: boolean;\n /**\n * Reject a submission whose `Origin` header doesn't match this origin.\n * Defaults to `context.canonical`'s origin. Pass `false` to disable.\n *\n * Best-effort, not a CSRF guard: browsers omit `Origin` on plenty of\n * legitimate same-origin requests, so its *absence* is never treated as a\n * mismatch — only a present-and-different value is rejected. That still stops\n * the common case (a script hitting the endpoint directly from elsewhere)\n * without breaking real submissions from older or privacy-hardened browsers.\n */\n allowedOrigin?: string | false;\n /**\n * Minimum interval between accepted submissions from the same IP. Pass\n * `false` to disable. `windowMs` defaults to 10 seconds — enough to blunt a\n * script firing in a loop without troubling someone who fat-fingers a form\n * and resubmits.\n */\n rateLimit?: { windowMs?: number; store?: RateLimitStore } | false;\n /**\n * Verify a Cloudflare Turnstile token server-side. The **secret** key —\n * unlike `site.security.turnstile.siteKey`, which is public config — must come\n * from an env var, never a config file (the same rule as every other\n * credential in this toolchain).\n */\n turnstile?: {\n secretKey: string;\n /** The submitted field carrying the token. Default `cf-turnstile-response`\n * — the name Cloudflare's widget submits under. */\n fieldName?: string;\n /**\n * Reject a submission with no token at all, rather than only a present-and-\n * invalid one. Default `false`: an absent token is the ordinary shape of a\n * visitor with JS disabled, and this factory is built around that path\n * staying usable. Set `true` only for a form where verified-human matters\n * enough to accept that trade-off — doing so means that form no longer\n * works without JS, on purpose.\n */\n required?: boolean;\n };\n}\n\n/** `notify()`'s own result, plus the CRM and Google Ads offline-upload\n * outcomes when those are configured. Additive over\n * `Awaited<ReturnType<typeof notify>>` — a consumer's existing `onResult`\n * reading `.results`/`.delivered` keeps compiling unchanged. */\nexport interface ConversionResult extends Awaited<ReturnType<typeof notify>> {\n crm?: CrmResult | undefined;\n googleAds?: GoogleAdsUploadResult | undefined;\n}\n\n/** The click id to report to Google Ads, checked in the same order as\n * `AD_PLATFORMS.google.clickIdParams` rather than `getPrimaryClickId()` —\n * that helper returns whichever platform's click id happens to appear first\n * in the *cross-platform* precedence table, which may not be Google's even\n * when a `gclid` is present. */\nfunction googleClickId(tracking: TrackingData | null): { param: string; value: string } | null {\n if (!tracking) return null;\n for (const param of AD_PLATFORMS.google.clickIdParams) {\n const value = tracking[param];\n if (value) return { param, value: String(value) };\n }\n return null;\n}\n\n/** Fields that are never part of a submission's meaning. `cf-turnstile-response`\n * is excluded unconditionally, matching `render.ts`'s `OMIT_FIELDS` — the field\n * carries a verification token, not something the visitor wrote. */\nconst CONTROL_FIELDS = new Set(['event', 'phone', 'cf-turnstile-response']);\n/** The capture script sends `link.href` as-is: `tel:` + digits and the punctuation people type\n * (a space arrives as `%20`). Not free text. */\nconst PHONE_RE = /^(?:tel:)?\\+?[\\d\\s().%-]{3,40}$/i;\n/** `mailto:` + an address, with an optional `?subject=…` query. A shape check, not deliverability. */\nconst EMAIL_RE = /^(?:mailto:)?[^\\s@<>\"?]{1,64}@[^\\s@<>\"?]{1,255}(?:\\?[^\\s<>\"]{0,256})?$/i;\n\nconst DEFAULT_HONEYPOT_FIELDS = ['website', '_honeypot', 'honeypot'];\nconst DEFAULT_MAX_BODY_BYTES = 25_000;\n\n/**\n * Read a request body, giving up as soon as it exceeds `max` bytes. Returns `null` when over\n * the cap. `Content-Length` is optional on chunked requests, so it cannot be the only guard.\n */\nasync function readCapped(request: Request, max: number): Promise<Uint8Array<ArrayBuffer> | null> {\n if (!request.body) return new Uint8Array();\n const reader = request.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > max) {\n await reader.cancel();\n return null;\n }\n chunks.push(value);\n }\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return bytes;\n}\nconst DEFAULT_RATE_LIMIT_WINDOW_MS = 10_000;\n\nfunction toFormData(\n body: Record<string, unknown>,\n honeypotFields: string[],\n): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(body)) {\n if (CONTROL_FIELDS.has(key) || honeypotFields.includes(key)) continue;\n if (value === undefined || value === null) continue;\n out[key] = String(value);\n }\n return out;\n}\n\nfunction hasHoneypot(body: Record<string, unknown>, honeypotFields: string[]): boolean {\n return honeypotFields.some((field) => {\n const value = body[field];\n return typeof value === 'string' && value.trim() !== '';\n });\n}\n\nfunction originOf(url: string | undefined): string | null {\n if (!url) return null;\n try {\n return new URL(url).origin;\n } catch {\n return null;\n }\n}\n\n/** A `Location` header response — used for the no-JS form path instead of\n * `Response.redirect()`, which requires a base URL this factory doesn't have. */\nfunction redirectTo(location: string, status = 303): Response {\n return new Response(null, { status, headers: { Location: location } });\n}\n\n/**\n * Build a POST handler for conversions.\n *\n * Returns 204 for a beacon and 200 with `{ success }` for a JSON (fetch-based)\n * form submission, matching what `navigator.sendBeacon` and a fetch-submitting\n * form each expect. A no-JS `<form method=\"post\">` — `application/\n * x-www-form-urlencoded` or `multipart/form-data` — is parsed the same way and,\n * on success, redirected to `redirectTo` if one is configured.\n *\n * **A failed notification does not fail the request.** The visitor has already\n * submitted; refusing them because our mail did not go out turns a lost\n * notification into a lost conversion. Failures are reported through `onResult`.\n */\nexport function createConversionRoute(options: ConversionRouteOptions) {\n const {\n context,\n binding,\n validate,\n onResult,\n now = Date.now,\n redirectTo: successRedirect,\n errorRedirectTo = successRedirect,\n honeypotFields = DEFAULT_HONEYPOT_FIELDS,\n maxBodyBytes = DEFAULT_MAX_BODY_BYTES,\n trustForwardedFor = false,\n } = options;\n\n const allowedOrigin =\n options.allowedOrigin === false ? null : (options.allowedOrigin ?? originOf(context.canonical));\n\n const rateLimit =\n options.rateLimit === false\n ? null\n : {\n windowMs: options.rateLimit?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,\n store: options.rateLimit?.store ?? new MemoryRateLimitStore(),\n };\n\n return async ({ request, locals }: ConversionRequestContext): Promise<Response> => {\n const contentType = request.headers.get('content-type') ?? '';\n const isFormPost =\n contentType.includes('multipart/form-data') ||\n contentType.includes('application/x-www-form-urlencoded');\n\n const respondError = (error: string, status: number): Response =>\n isFormPost && errorRedirectTo\n ? redirectTo(errorRedirectTo)\n : Response.json({ success: false, error }, { status });\n\n const contentLength = Number(request.headers.get('content-length') ?? 0);\n if (contentLength > maxBodyBytes) {\n return respondError('Request too large.', 413);\n }\n\n if (allowedOrigin) {\n const requestOrigin = request.headers.get('origin');\n if (requestOrigin && requestOrigin !== allowedOrigin) {\n return respondError('Origin not allowed.', 403);\n }\n }\n\n // A chunked request has no Content-Length to check above, so cap the stream itself.\n const bytes = await readCapped(request, maxBodyBytes);\n if (!bytes) return respondError('Request too large.', 413);\n\n let body: Record<string, unknown>;\n try {\n if (isFormPost) {\n const formData = await new Response(bytes, {\n headers: { 'content-type': contentType },\n }).formData();\n body = {};\n for (const [key, value] of formData.entries()) {\n if (typeof value !== 'string') continue;\n // Several values under one name (a group of tick boxes) join, so none is lost to \"last wins\".\n const prior = body[key];\n body[key] = typeof prior === 'string' && prior ? `${prior}, ${value}` : value;\n }\n } else {\n // Not just `application/json`: `navigator.sendBeacon` sends a bare\n // string as `text/plain`, and this must keep accepting that body.\n const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return respondError('Malformed request.', 400);\n }\n body = parsed as Record<string, unknown>;\n }\n } catch {\n return respondError('Malformed request.', 400);\n }\n\n const ip =\n request.headers.get('cf-connecting-ip') ??\n (trustForwardedFor ? request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() : null) ??\n undefined;\n\n // Classified before rate limiting, not read raw off the client — see the key\n // below. `phone`/`email` presence is still checked after limiting, since that\n // check just rejects a malformed beacon rather than deciding the bucket.\n const isCall = body['event'] === 'call';\n const isEmailClick = body['event'] === 'email';\n\n // Keyed on IP *and* the server's own classification (`call` | `email` |\n // `form`), never the raw `event` string the client sent. A call beacon and an\n // email-click beacon from the same visitor are two distinct, legitimate\n // conversions, not a repeat of one, so they need separate buckets — but\n // trusting the raw string for that also let a client dodge the form rate\n // limit entirely by varying `event` on every request (any value other than\n // exactly `'call'`/`'email'` used to get its own bucket). Collapsing\n // everything else to `'form'` closes that without losing the call/email split.\n if (rateLimit && ip) {\n const rlKey = `${ip}:${isCall ? 'call' : isEmailClick ? 'email' : 'form'}`;\n const sinceLastHit = await rateLimit.store.hit(rlKey, now());\n if (sinceLastHit !== null && sinceLastHit < rateLimit.windowMs) {\n return respondError('Too many requests.', 429);\n }\n }\n\n // A beacon skips every form check, so its one payload field is format-checked instead:\n // the value is interpolated into a notification someone reads, and an unauthenticated\n // caller should not be able to put arbitrary text there.\n if (isCall && !(typeof body['phone'] === 'string' && PHONE_RE.test(body['phone']))) {\n return respondError('Missing phone.', 400);\n }\n\n if (isEmailClick && !(typeof body['email'] === 'string' && EMAIL_RE.test(body['email']))) {\n return respondError('Missing email.', 400);\n }\n\n // A call and an email-click beacon are the same shape of thing: a fire-and-\n // forget ping with no form fields to validate, honeypot, verify or map into\n // a CRM — both skip every check below that exists for a form submission.\n const isBeacon = isCall || isEmailClick;\n\n const spam = !isBeacon && hasHoneypot(body, honeypotFields);\n\n if (!isBeacon && !spam && options.turnstile) {\n const {\n secretKey,\n fieldName = 'cf-turnstile-response',\n required = false,\n } = options.turnstile;\n const token = body[fieldName];\n if (typeof token === 'string' && token) {\n const verification = await verifyTurnstileToken(token, secretKey, ip);\n if (!verification.success) return respondError('Verification failed.', 400);\n } else if (required) {\n return respondError('Verification required.', 400);\n }\n }\n\n const refusal = !spam && validate?.(body);\n if (refusal) return respondError(refusal, 400);\n\n const respondSuccess = (): Response =>\n isBeacon\n ? new Response(null, { status: 204 })\n : isFormPost && successRedirect\n ? redirectTo(successRedirect)\n : Response.json({ success: true });\n\n // A honeypot trip never reaches `notify()` — the response is identical to a\n // real success, so a bot gets no signal that it was caught.\n if (spam) {\n onResult?.({\n results: [{ channel: 'email', sent: false, reason: 'Skipped: honeypot triggered.' }],\n delivered: false,\n });\n return respondSuccess();\n }\n\n const turnstileFieldName = options.turnstile?.fieldName;\n const excludedFields = turnstileFieldName\n ? [...honeypotFields, turnstileFieldName]\n : honeypotFields;\n\n const event: ConversionEvent = {\n type: isCall ? 'call' : isEmailClick ? 'email' : 'form',\n ...(isCall\n ? { phone: String(body['phone']).replace(/^tel:/, '') }\n : isEmailClick\n ? { email: String(body['email']).replace(/^mailto:/, '') }\n : { formData: toFormData(body, excludedFields) }),\n tracking: parseTrackingCookie(request.headers.get('cookie')),\n userAgent: request.headers.get('user-agent') ?? undefined,\n ip,\n at: now(),\n };\n\n // CRM before notify: the notification email states the CRM outcome, which\n // is only possible once it's known. Never attempted for a `tel:`/`mailto:`\n // beacon — a CRM contact comes from submitted form fields, and neither\n // event has any to map.\n let crmResult: CrmResult | null = null;\n if (!isBeacon && options.crm) {\n crmResult = await deliverToCrm(event, options.crm.config, {\n credentials: options.crm.credentials?.(locals),\n fetch: options.crm.fetch,\n timeoutMs: options.crm.timeoutMs,\n // Provider-neutral: HubSpot reads `hutk` to stitch the submission to\n // its own tracking; Jobber reads `pageUri` for its note body. Neither\n // is HubSpot-specific to build, so this is passed unconditionally\n // rather than gated on `options.crm.config.provider`.\n pageContext: {\n hutk: readCookie(request.headers.get('cookie'), 'hubspotutk') ?? undefined,\n pageUri: request.headers.get('referer') ?? undefined,\n },\n });\n }\n const crmNote = describeCrmResult(crmResult);\n\n // Unlike CRM, this runs for a `tel:`/`mailto:` beacon too — those are\n // exactly the actions the client-side gtag handler would otherwise fire\n // for. Skipped, not failed, when there's no click id to attribute, no\n // `conversionActionId` configured for this action, or no credentials for\n // this environment (e.g. local dev) — none of those is a real failure.\n let googleAdsResult: GoogleAdsUploadResult | null = null;\n const adsCfg = options.googleAdsOffline?.config;\n if (adsCfg?.reporting === 'server' && adsCfg.accountId) {\n const actionId = normalizeConversionActionIds(adsCfg.conversionActionId)[event.type];\n const clickId = googleClickId(event.tracking);\n const credentials = options.googleAdsOffline?.credentials?.(locals);\n if (actionId && clickId && credentials) {\n googleAdsResult = await uploadClickConversion(\n { customerId: adsCfg.accountId, conversionActionId: actionId, clickId, at: event.at },\n credentials,\n options.googleAdsOffline?.fetch,\n );\n }\n }\n const googleAdsNote = describeGoogleAdsOfflineResult(googleAdsResult);\n\n const result: ConversionResult = {\n ...(await notify(\n event,\n context,\n { email: binding?.(locals) },\n {\n notes: [crmNote, googleAdsNote].filter((n): n is string => n != null),\n },\n )),\n ...(crmResult ? { crm: crmResult } : {}),\n ...(googleAdsResult ? { googleAds: googleAdsResult } : {}),\n };\n\n if (onResult) onResult(result);\n else {\n for (const r of result.results) {\n if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);\n }\n if (crmResult && !crmResult.ok) {\n console.warn(\n `[vitops] CRM write not delivered (${crmResult.provider}): ${crmResult.reason}`,\n );\n }\n if (googleAdsResult && !googleAdsResult.ok) {\n console.warn(`[vitops] Google Ads offline upload not delivered: ${googleAdsResult.reason}`);\n }\n }\n\n return respondSuccess();\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"conversion.mjs","names":[],"sources":["../../src/routes/conversion.ts"],"sourcesContent":["/**\n * The conversion endpoint, as a factory.\n *\n * A site's contact form and its `tel:`/`mailto:` beacons all mean the same\n * thing — someone got in touch — and all want the same treatment: read the\n * attribution cookie off the request, build the event, notify whoever the\n * config says. That is the same three lines in every project, so it ships\n * here.\n *\n * What deliberately does **not** ship here is field-level validation and business\n * rules — a consumer's form has its own required fields and its own response\n * shape, and baking a guess at those in would make the factory something to\n * fight rather than mount. The handler takes the parsed body and an optional\n * `validate`; that part is the caller's. What **does** ship here is generic abuse\n * resistance (honeypot, origin, rate limit, body size): this is a public,\n * unauthenticated endpoint that sends email, so leaving it wide open makes it a\n * relay and a cost amplifier regardless of whose form is behind it.\n *\n * The path matters: the capture script beacons `tel:`/`mailto:` conversions at\n * `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),\n * and the integration warns at build if no route answers it.\n *\n * ```ts\n * // src/pages/api/track.ts\n * export const prerender = false;\n * import { env } from 'cloudflare:workers';\n * import { createConversionRoute } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: {\n * // A bare address is shorthand for `{ provider: 'cloudflare', to }`.\n * notifications: { email: 'sales@acme.ca' },\n * canonical: 'https://acme.ca',\n * organizationName: 'Acme',\n * },\n * binding: () => env.EMAIL,\n * // A no-JS <form method=\"post\"> submission never sees a JSON response body —\n * // it's a full navigation, so send it somewhere real.\n * redirectTo: '/thanks',\n * });\n * ```\n *\n * Cloudflare Turnstile is a separate, opt-in layer on top of the checks above —\n * pass `turnstile: { secretKey }` and a submission carrying a\n * `cf-turnstile-response` token gets it verified server-side via\n * `verifyTurnstileToken` (`@getvitops/utils/security`). The widget itself is the\n * consumer's own markup (`<script src=\"https://challenges.cloudflare.com/\n * turnstile/v0/api.js\" async defer></script>` + `<div class=\"cf-turnstile\"\n * data-sitekey=\"…\">` on their form) — there is no `FormRenderer` for this to hook\n * into generically, matching how the rest of the form is already hand-written.\n * `site.security.turnstile.siteKey` is what the generated privacy policy reads to\n * name Turnstile as a processor; wiring this option up is what makes that true\n * rather than an assertion nothing checks.\n *\n * Two things this example used to get wrong, both of which cost a consumer real\n * time. It read the binding off `locals.runtime.env`, which Astro removed in v6\n * — and because the resulting throw precedes the response, Astro re-reads the\n * request and reports a misleading \"Body has already been used\" instead of the\n * actual error. This package peers on `astro >= 7`, so that form could not work\n * for any supported consumer; `import { env } from 'cloudflare:workers'` is the\n * current one. It also called a `toNotifyContext(config)` helper that used to\n * not exist — `context` can now be built with `toNotifyContext(config)` (also\n * exported from this module), rather than every consumer hand-writing a\n * `NotifyContext` literal:\n *\n * ```ts\n * import config from '../../site.json' with { type: 'json' };\n * import { createConversionRoute, toNotifyContext, toCrmConfig } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: toNotifyContext(config),\n * crm: toCrmConfig(config) && { config: toCrmConfig(config)!, credentials: () => ({ ... }) },\n * binding: () => env.EMAIL,\n * redirectTo: '/thanks',\n * });\n * ```\n */\nimport { AD_PLATFORMS, normalizeConversionActionIds } from '@getvitops/utils/ads';\nimport type { AdPropertySetup } from '@getvitops/utils/ads';\nimport { deliverToCrm, describeCrmResult } from '@getvitops/utils/crm';\nimport type { CrmConfig, CrmCredentials, CrmResult } from '@getvitops/utils/crm';\nimport {\n describeGoogleAdsOfflineResult,\n type EmailBinding,\n notify,\n type NotifyContext,\n uploadClickConversion,\n type GoogleAdsCredentials,\n type GoogleAdsUploadResult,\n} from '@getvitops/utils/notify';\nimport { verifyTurnstileToken } from '@getvitops/utils/security';\nimport { parseTrackingCookie, readCookie } from '@getvitops/utils/tracking';\nimport type { ConversionEvent, TrackingData } from '@getvitops/utils/tracking';\nexport {\n resolveOrganizationName,\n toCrmConfig,\n toGoogleAdsConfig,\n toNotifyContext,\n} from './config-adapters.ts';\n\n/** The subset of Astro's `APIContext` this needs — typed structurally so the\n * factory does not pin an Astro version. */\nexport interface ConversionRequestContext {\n request: Request;\n locals?: unknown;\n}\n\n/**\n * Records a hit for `id` at time `at` (ms) and returns the ms since the previous\n * hit for that `id`, or `null` if this is the first. Pure interface — a store\n * decides nothing, it only remembers.\n */\nexport interface RateLimitStore {\n hit(id: string, at: number): Promise<number | null> | number | null;\n}\n\n/**\n * Per-isolate, in-memory default. Good enough for a single Worker instance\n * fielding occasional contact-form spam; not durable across a fleet, and not\n * shared with other routes. Inject a KV- or Durable-Object-backed `store` for\n * real cross-instance limiting — the interface is one method wide on purpose.\n */\nclass MemoryRateLimitStore implements RateLimitStore {\n private lastHit = new Map<string, number>();\n hit(id: string, at: number): number | null {\n const prev = this.lastHit.get(id);\n this.lastHit.set(id, at);\n return prev === undefined ? null : at - prev;\n }\n}\n\nexport interface ConversionRouteOptions {\n /** Where notifications go. Build it from your site config. */\n context: NotifyContext;\n /**\n * The Workers `send_email` binding for this request, e.g. `() => env.EMAIL`\n * with `env` imported from `cloudflare:workers`. Omit it in dev and the\n * notification is printed instead of sent.\n *\n * Not `(locals) => locals.runtime.env.EMAIL` — Astro removed `locals.runtime`\n * in v6, and this package peers on `>= 7`.\n */\n binding?: (locals: unknown) => EmailBinding | undefined;\n /**\n * Push the submission into a CRM (HubSpot, Zoho Bigin or Jobber) before notifying.\n * Credentials arrive as a function of `locals`, exactly like `binding` above\n * — the consumer reads them from `import { env } from 'cloudflare:workers'`.\n * Credentials are never config; that rule is absolute in this toolchain.\n *\n * A failed CRM write never fails the request and never blocks the honeypot/\n * Turnstile gates above it — those run first, so a bot never reaches the\n * CRM. The outcome is folded into the notification email's \"Delivery\"\n * section (via `describeCrmResult`) so a failed write is never silent.\n */\n crm?: {\n config: CrmConfig;\n credentials?: (locals: unknown) => CrmCredentials | undefined;\n fetch?: typeof fetch;\n timeoutMs?: number;\n };\n /**\n * Upload the conversion to Google Ads server-side instead of relying on the\n * visitor's browser to fire `gtag.js` — see `site.ads.google.reporting` and\n * `conversionActionId`. Credentials arrive as a function of `locals`, same\n * shape as `crm.credentials` and `binding` above.\n *\n * Only fires for an action whose `conversionActionId` is actually set (via\n * `toGoogleAdsConfig(config)`, or hand-built the same shape) **and** whose\n * event carries a Google click id (`gclid`/`gbraid`/`wbraid` on `_ac`) —\n * an organic conversion has nothing for the Ads API to attribute, so it's\n * silently skipped rather than reported as a failure. A failed upload never\n * fails the request, matching `crm` above; the outcome folds into the same\n * \"Delivery\" section via `describeGoogleAdsOfflineResult`.\n */\n googleAdsOffline?: {\n config: Pick<AdPropertySetup, 'accountId' | 'conversionActionId' | 'reporting'>;\n credentials?: (locals: unknown) => GoogleAdsCredentials | undefined;\n fetch?: typeof fetch;\n };\n /**\n * Reject a submission before anything is sent. Return a message to refuse.\n * This is where a consumer puts required-field checks.\n */\n validate?: (body: Record<string, unknown>) => string | null;\n /** Called with every channel outcome, delivered or not, plus the CRM\n * outcome when `crm` is configured. Defaults to console. */\n onResult?: (result: ConversionResult) => void;\n /** Injected in tests. */\n now?: () => number;\n /**\n * Where to send a no-JS `<form method=\"post\">` submission on success. Without\n * this, that visitor's browser navigates to the raw `{\"success\":true}` JSON\n * body as its next page — functional, but not a page anyone should see. Has\n * no effect on the beacon/fetch (JSON) path, which always gets a JSON response.\n */\n redirectTo?: string;\n /**\n * Where to send a refused no-JS submission (validation, spam checks, malformed\n * body). Falls back to `redirectTo` if omitted, and to the JSON error response\n * if neither is set.\n */\n errorRedirectTo?: string;\n /**\n * Field names treated as honeypots: a bot fills every input it finds, a person\n * never sees these (hidden by CSS in the form markup). A non-empty value is\n * silently treated as spam — the response looks identical to a real success,\n * denying the bot the signal it needs to know the trick worked, but nothing is\n * notified and the field never reaches `notify()`. Defaults to the same names\n * `render.ts`'s `OMIT_FIELDS` already knows about (`website`, `_honeypot`,\n * `honeypot`), so the two lists don't need to be kept in sync by hand.\n */\n honeypotFields?: string[];\n /**\n * Reject a submission whose `Content-Length` exceeds this many bytes. Default\n * 25_000 — generous for a contact form with no file upload, tiny for anything\n * meant to relay large payloads. The header is only a fast path: a chunked\n * request carries none, so the body is also read through a byte-capped\n * reader and refused the moment it passes the cap.\n */\n maxBodyBytes?: number;\n /**\n * Fall back to the first `X-Forwarded-For` hop as the client IP when\n * `CF-Connecting-IP` is absent. Default `false`: off Cloudflare the header is\n * whatever the client sent, so honouring it lets a script mint a fresh\n * rate-limit bucket per request. Turn on only behind a proxy that overwrites\n * it. With neither header trusted there is no IP, and rate limiting is skipped.\n */\n trustForwardedFor?: boolean;\n /**\n * Reject a submission whose `Origin` header doesn't match this origin.\n * Defaults to `context.canonical`'s origin. Pass `false` to disable.\n *\n * Best-effort, not a CSRF guard: browsers omit `Origin` on plenty of\n * legitimate same-origin requests, so its *absence* is never treated as a\n * mismatch — only a present-and-different value is rejected. That still stops\n * the common case (a script hitting the endpoint directly from elsewhere)\n * without breaking real submissions from older or privacy-hardened browsers.\n */\n allowedOrigin?: string | false;\n /**\n * Minimum interval between accepted submissions from the same IP. Pass\n * `false` to disable. `windowMs` defaults to 10 seconds — enough to blunt a\n * script firing in a loop without troubling someone who fat-fingers a form\n * and resubmits.\n */\n rateLimit?: { windowMs?: number; store?: RateLimitStore } | false;\n /**\n * Verify a Cloudflare Turnstile token server-side. The **secret** key —\n * unlike `site.security.turnstile.siteKey`, which is public config — must come\n * from an env var, never a config file (the same rule as every other\n * credential in this toolchain).\n */\n turnstile?: {\n secretKey: string;\n /** The submitted field carrying the token. Default `cf-turnstile-response`\n * — the name Cloudflare's widget submits under. */\n fieldName?: string;\n /**\n * Reject a submission with no token at all, rather than only a present-and-\n * invalid one. Default `false`: an absent token is the ordinary shape of a\n * visitor with JS disabled, and this factory is built around that path\n * staying usable. Set `true` only for a form where verified-human matters\n * enough to accept that trade-off — doing so means that form no longer\n * works without JS, on purpose.\n */\n required?: boolean;\n };\n}\n\n/** `notify()`'s own result, plus the CRM and Google Ads offline-upload\n * outcomes when those are configured. Additive over\n * `Awaited<ReturnType<typeof notify>>` — a consumer's existing `onResult`\n * reading `.results`/`.delivered` keeps compiling unchanged. */\nexport interface ConversionResult extends Awaited<ReturnType<typeof notify>> {\n crm?: CrmResult | undefined;\n googleAds?: GoogleAdsUploadResult | undefined;\n}\n\n/** The click id to report to Google Ads, checked in the same order as\n * `AD_PLATFORMS.google.clickIdParams` rather than `getPrimaryClickId()` —\n * that helper returns whichever platform's click id happens to appear first\n * in the *cross-platform* precedence table, which may not be Google's even\n * when a `gclid` is present. */\nfunction googleClickId(tracking: TrackingData | null): { param: string; value: string } | null {\n if (!tracking) return null;\n for (const param of AD_PLATFORMS.google.clickIdParams) {\n const value = tracking[param];\n if (value) return { param, value: String(value) };\n }\n return null;\n}\n\n/** Fields that are never part of a submission's meaning. `cf-turnstile-response`\n * is excluded unconditionally, matching `render.ts`'s `OMIT_FIELDS` — the field\n * carries a verification token, not something the visitor wrote. */\nconst CONTROL_FIELDS = new Set(['event', 'phone', 'phoneSource', 'cf-turnstile-response']);\n/** The capture script sends `link.href` as-is: `tel:` + digits and the punctuation people type\n * (a space arrives as `%20`). Not free text. */\nconst PHONE_RE = /^(?:tel:)?\\+?[\\d\\s().%-]{3,40}$/i;\n/** A `site.tracking.phoneNumbers` source key (`meta`, `default-paid`, …). Printed in a notification. */\nconst PHONE_SOURCE_RE = /^[a-z][a-z-]{1,19}$/;\n/** `mailto:` + an address, with an optional `?subject=…` query. A shape check, not deliverability. */\nconst EMAIL_RE = /^(?:mailto:)?[^\\s@<>\"?]{1,64}@[^\\s@<>\"?]{1,255}(?:\\?[^\\s<>\"]{0,256})?$/i;\n\nconst DEFAULT_HONEYPOT_FIELDS = ['website', '_honeypot', 'honeypot'];\nconst DEFAULT_MAX_BODY_BYTES = 25_000;\n\n/**\n * Read a request body, giving up as soon as it exceeds `max` bytes. Returns `null` when over\n * the cap. `Content-Length` is optional on chunked requests, so it cannot be the only guard.\n */\nasync function readCapped(request: Request, max: number): Promise<Uint8Array<ArrayBuffer> | null> {\n if (!request.body) return new Uint8Array();\n const reader = request.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > max) {\n await reader.cancel();\n return null;\n }\n chunks.push(value);\n }\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return bytes;\n}\nconst DEFAULT_RATE_LIMIT_WINDOW_MS = 10_000;\n\nfunction toFormData(\n body: Record<string, unknown>,\n honeypotFields: string[],\n): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(body)) {\n if (CONTROL_FIELDS.has(key) || honeypotFields.includes(key)) continue;\n if (value === undefined || value === null) continue;\n out[key] = String(value);\n }\n return out;\n}\n\nfunction hasHoneypot(body: Record<string, unknown>, honeypotFields: string[]): boolean {\n return honeypotFields.some((field) => {\n const value = body[field];\n return typeof value === 'string' && value.trim() !== '';\n });\n}\n\nfunction originOf(url: string | undefined): string | null {\n if (!url) return null;\n try {\n return new URL(url).origin;\n } catch {\n return null;\n }\n}\n\n/** A `Location` header response — used for the no-JS form path instead of\n * `Response.redirect()`, which requires a base URL this factory doesn't have. */\nfunction redirectTo(location: string, status = 303): Response {\n return new Response(null, { status, headers: { Location: location } });\n}\n\n/**\n * Build a POST handler for conversions.\n *\n * Returns 204 for a beacon and 200 with `{ success }` for a JSON (fetch-based)\n * form submission, matching what `navigator.sendBeacon` and a fetch-submitting\n * form each expect. A no-JS `<form method=\"post\">` — `application/\n * x-www-form-urlencoded` or `multipart/form-data` — is parsed the same way and,\n * on success, redirected to `redirectTo` if one is configured.\n *\n * **A failed notification does not fail the request.** The visitor has already\n * submitted; refusing them because our mail did not go out turns a lost\n * notification into a lost conversion. Failures are reported through `onResult`.\n */\nexport function createConversionRoute(options: ConversionRouteOptions) {\n const {\n context,\n binding,\n validate,\n onResult,\n now = Date.now,\n redirectTo: successRedirect,\n errorRedirectTo = successRedirect,\n honeypotFields = DEFAULT_HONEYPOT_FIELDS,\n maxBodyBytes = DEFAULT_MAX_BODY_BYTES,\n trustForwardedFor = false,\n } = options;\n\n const allowedOrigin =\n options.allowedOrigin === false ? null : (options.allowedOrigin ?? originOf(context.canonical));\n\n const rateLimit =\n options.rateLimit === false\n ? null\n : {\n windowMs: options.rateLimit?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,\n store: options.rateLimit?.store ?? new MemoryRateLimitStore(),\n };\n\n return async ({ request, locals }: ConversionRequestContext): Promise<Response> => {\n const contentType = request.headers.get('content-type') ?? '';\n const isFormPost =\n contentType.includes('multipart/form-data') ||\n contentType.includes('application/x-www-form-urlencoded');\n\n const respondError = (error: string, status: number): Response =>\n isFormPost && errorRedirectTo\n ? redirectTo(errorRedirectTo)\n : Response.json({ success: false, error }, { status });\n\n const contentLength = Number(request.headers.get('content-length') ?? 0);\n if (contentLength > maxBodyBytes) {\n return respondError('Request too large.', 413);\n }\n\n if (allowedOrigin) {\n const requestOrigin = request.headers.get('origin');\n if (requestOrigin && requestOrigin !== allowedOrigin) {\n return respondError('Origin not allowed.', 403);\n }\n }\n\n // A chunked request has no Content-Length to check above, so cap the stream itself.\n const bytes = await readCapped(request, maxBodyBytes);\n if (!bytes) return respondError('Request too large.', 413);\n\n let body: Record<string, unknown>;\n try {\n if (isFormPost) {\n const formData = await new Response(bytes, {\n headers: { 'content-type': contentType },\n }).formData();\n body = {};\n for (const [key, value] of formData.entries()) {\n if (typeof value !== 'string') continue;\n // Several values under one name (a group of tick boxes) join, so none is lost to \"last wins\".\n const prior = body[key];\n body[key] = typeof prior === 'string' && prior ? `${prior}, ${value}` : value;\n }\n } else {\n // Not just `application/json`: `navigator.sendBeacon` sends a bare\n // string as `text/plain`, and this must keep accepting that body.\n const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return respondError('Malformed request.', 400);\n }\n body = parsed as Record<string, unknown>;\n }\n } catch {\n return respondError('Malformed request.', 400);\n }\n\n const ip =\n request.headers.get('cf-connecting-ip') ??\n (trustForwardedFor ? request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() : null) ??\n undefined;\n\n // Classified before rate limiting, not read raw off the client — see the key\n // below. `phone`/`email` presence is still checked after limiting, since that\n // check just rejects a malformed beacon rather than deciding the bucket.\n const isCall = body['event'] === 'call';\n const isEmailClick = body['event'] === 'email';\n\n // Keyed on IP *and* the server's own classification (`call` | `email` |\n // `form`), never the raw `event` string the client sent. A call beacon and an\n // email-click beacon from the same visitor are two distinct, legitimate\n // conversions, not a repeat of one, so they need separate buckets — but\n // trusting the raw string for that also let a client dodge the form rate\n // limit entirely by varying `event` on every request (any value other than\n // exactly `'call'`/`'email'` used to get its own bucket). Collapsing\n // everything else to `'form'` closes that without losing the call/email split.\n if (rateLimit && ip) {\n const rlKey = `${ip}:${isCall ? 'call' : isEmailClick ? 'email' : 'form'}`;\n const sinceLastHit = await rateLimit.store.hit(rlKey, now());\n if (sinceLastHit !== null && sinceLastHit < rateLimit.windowMs) {\n return respondError('Too many requests.', 429);\n }\n }\n\n // A beacon skips every form check, so its one payload field is format-checked instead:\n // the value is interpolated into a notification someone reads, and an unauthenticated\n // caller should not be able to put arbitrary text there.\n if (isCall && !(typeof body['phone'] === 'string' && PHONE_RE.test(body['phone']))) {\n return respondError('Missing phone.', 400);\n }\n\n if (isEmailClick && !(typeof body['email'] === 'string' && EMAIL_RE.test(body['email']))) {\n return respondError('Missing email.', 400);\n }\n\n // A call and an email-click beacon are the same shape of thing: a fire-and-\n // forget ping with no form fields to validate, honeypot, verify or map into\n // a CRM — both skip every check below that exists for a form submission.\n const isBeacon = isCall || isEmailClick;\n\n const spam = !isBeacon && hasHoneypot(body, honeypotFields);\n\n if (!isBeacon && !spam && options.turnstile) {\n const {\n secretKey,\n fieldName = 'cf-turnstile-response',\n required = false,\n } = options.turnstile;\n const token = body[fieldName];\n if (typeof token === 'string' && token) {\n const verification = await verifyTurnstileToken(token, secretKey, ip);\n if (!verification.success) return respondError('Verification failed.', 400);\n } else if (required) {\n return respondError('Verification required.', 400);\n }\n }\n\n const refusal = !spam && validate?.(body);\n if (refusal) return respondError(refusal, 400);\n\n const respondSuccess = (): Response =>\n isBeacon\n ? new Response(null, { status: 204 })\n : isFormPost && successRedirect\n ? redirectTo(successRedirect)\n : Response.json({ success: true });\n\n // A honeypot trip never reaches `notify()` — the response is identical to a\n // real success, so a bot gets no signal that it was caught.\n if (spam) {\n onResult?.({\n results: [{ channel: 'email', sent: false, reason: 'Skipped: honeypot triggered.' }],\n delivered: false,\n });\n return respondSuccess();\n }\n\n const turnstileFieldName = options.turnstile?.fieldName;\n const excludedFields = turnstileFieldName\n ? [...honeypotFields, turnstileFieldName]\n : honeypotFields;\n\n const event: ConversionEvent = {\n type: isCall ? 'call' : isEmailClick ? 'email' : 'form',\n ...(isCall\n ? {\n phone: String(body['phone']).replace(/^tel:/, ''),\n ...(typeof body['phoneSource'] === 'string' && PHONE_SOURCE_RE.test(body['phoneSource'])\n ? { phoneSource: body['phoneSource'] }\n : {}),\n }\n : isEmailClick\n ? { email: String(body['email']).replace(/^mailto:/, '') }\n : { formData: toFormData(body, excludedFields) }),\n tracking: parseTrackingCookie(request.headers.get('cookie')),\n userAgent: request.headers.get('user-agent') ?? undefined,\n ip,\n at: now(),\n };\n\n // CRM before notify: the notification email states the CRM outcome, which\n // is only possible once it's known. Never attempted for a `tel:`/`mailto:`\n // beacon — a CRM contact comes from submitted form fields, and neither\n // event has any to map.\n let crmResult: CrmResult | null = null;\n if (!isBeacon && options.crm) {\n crmResult = await deliverToCrm(event, options.crm.config, {\n credentials: options.crm.credentials?.(locals),\n fetch: options.crm.fetch,\n timeoutMs: options.crm.timeoutMs,\n // Provider-neutral: HubSpot reads `hutk` to stitch the submission to\n // its own tracking; Jobber reads `pageUri` for its note body. Neither\n // is HubSpot-specific to build, so this is passed unconditionally\n // rather than gated on `options.crm.config.provider`.\n pageContext: {\n hutk: readCookie(request.headers.get('cookie'), 'hubspotutk') ?? undefined,\n pageUri: request.headers.get('referer') ?? undefined,\n },\n });\n }\n const crmNote = describeCrmResult(crmResult);\n\n // Unlike CRM, this runs for a `tel:`/`mailto:` beacon too — those are\n // exactly the actions the client-side gtag handler would otherwise fire\n // for. Skipped, not failed, when there's no click id to attribute, no\n // `conversionActionId` configured for this action, or no credentials for\n // this environment (e.g. local dev) — none of those is a real failure.\n let googleAdsResult: GoogleAdsUploadResult | null = null;\n const adsCfg = options.googleAdsOffline?.config;\n if (adsCfg?.reporting === 'server' && adsCfg.accountId) {\n const actionId = normalizeConversionActionIds(adsCfg.conversionActionId)[event.type];\n const clickId = googleClickId(event.tracking);\n const credentials = options.googleAdsOffline?.credentials?.(locals);\n if (actionId && clickId && credentials) {\n googleAdsResult = await uploadClickConversion(\n { customerId: adsCfg.accountId, conversionActionId: actionId, clickId, at: event.at },\n credentials,\n options.googleAdsOffline?.fetch,\n );\n }\n }\n const googleAdsNote = describeGoogleAdsOfflineResult(googleAdsResult);\n\n const result: ConversionResult = {\n ...(await notify(\n event,\n context,\n { email: binding?.(locals) },\n {\n notes: [crmNote, googleAdsNote].filter((n): n is string => n != null),\n },\n )),\n ...(crmResult ? { crm: crmResult } : {}),\n ...(googleAdsResult ? { googleAds: googleAdsResult } : {}),\n };\n\n if (onResult) onResult(result);\n else {\n for (const r of result.results) {\n if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);\n }\n if (crmResult && !crmResult.ok) {\n console.warn(\n `[vitops] CRM write not delivered (${crmResult.provider}): ${crmResult.reason}`,\n );\n }\n if (googleAdsResult && !googleAdsResult.ok) {\n console.warn(`[vitops] Google Ads offline upload not delivered: ${googleAdsResult.reason}`);\n }\n }\n\n return respondSuccess();\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/astro",
3
- "version": "8.3.0-alpha.4",
3
+ "version": "8.3.0-alpha.5",
4
4
  "description": "Astro integration for the Vitops design system — favicons/PWA, web-component scripts, and CSS, via a getvitops() integration + <Head /> component.",
5
5
  "homepage": "https://docs.vitops.ca",
6
6
  "bugs": "https://github.com/getvitops/design-system/issues",
@@ -41,6 +41,8 @@
41
41
  "src/components/SiteNavList.astro",
42
42
  "src/components/SiteFooter.astro",
43
43
  "src/components/sitenav.ts",
44
+ "src/components/Masthead.astro",
45
+ "src/components/masthead.ts",
44
46
  "src/schemas",
45
47
  "src/json-ld.ts",
46
48
  "virtual.d.ts",
@@ -98,6 +100,8 @@
98
100
  "./components/SiteNav.astro": "./src/components/SiteNav.astro",
99
101
  "./components/SiteFooter.astro": "./src/components/SiteFooter.astro",
100
102
  "./components/sitenav.ts": "./src/components/sitenav.ts",
103
+ "./components/Masthead.astro": "./src/components/Masthead.astro",
104
+ "./components/masthead.ts": "./src/components/masthead.ts",
101
105
  "./components/Tree.astro": "./src/components/Tree.astro",
102
106
  "./components/tree.ts": "./src/components/tree.ts",
103
107
  "./components/ReviewCard.astro": "./src/components/ReviewCard.astro",
@@ -109,11 +113,11 @@
109
113
  "access": "public"
110
114
  },
111
115
  "dependencies": {
112
- "@getvitops/config": "8.3.0-alpha.4",
113
- "@getvitops/core": "8.3.0-alpha.4",
114
- "@getvitops/generator": "8.3.0-alpha.4",
115
- "@getvitops/utils": "8.3.0-alpha.4",
116
- "@getvitops/vite": "8.3.0-alpha.4"
116
+ "@getvitops/config": "8.3.0-alpha.5",
117
+ "@getvitops/core": "8.3.0-alpha.5",
118
+ "@getvitops/generator": "8.3.0-alpha.5",
119
+ "@getvitops/utils": "8.3.0-alpha.5",
120
+ "@getvitops/vite": "8.3.0-alpha.5"
117
121
  },
118
122
  "devDependencies": {
119
123
  "@astrojs/sitemap": "^3.7.3",
package/src/ads.ts CHANGED
@@ -47,6 +47,7 @@ export type GetvitopsAdProperty = Pick<
47
47
  | 'pixelId'
48
48
  | 'conversionLabel'
49
49
  | 'conversionActionId'
50
+ | 'callTracking'
50
51
  | 'reporting'
51
52
  | 'category'
52
53
  | 'enabled'
@@ -86,6 +87,9 @@ export interface ResolveAdsContext {
86
87
  */
87
88
  const SAFE_ID = /^[\w.-]+$/;
88
89
 
90
+ /** A displayed phone number: digits and the punctuation people write around them. */
91
+ const SAFE_PHONE = /^\+?[\d\s().-]{7,20}$/;
92
+
89
93
  /** Resolve the configured ad properties into the tags `<Ads />` emits. */
90
94
  export function resolveAds(
91
95
  ads: GetvitopsAdsOptions | undefined,
@@ -149,6 +153,24 @@ export function resolveAds(
149
153
  : rest;
150
154
  }
151
155
 
156
+ // `callTracking` lands in the same inline `<script>`. Its label gets `SAFE_ID`; its
157
+ // number gets a phone pattern. A bad one drops only call tracking, not the tag.
158
+ if (entry.callTracking) {
159
+ const { label, number } = entry.callTracking;
160
+ const bad = !SAFE_ID.test(label)
161
+ ? `callTracking.label "${label}" is not a valid label`
162
+ : !SAFE_PHONE.test(number)
163
+ ? `callTracking.number "${number}" is not a valid phone number`
164
+ : !platform.websiteCallTracking
165
+ ? `${platform.name} has no website call tracking`
166
+ : null;
167
+ if (bad) {
168
+ warnings.push(`ads: ${provider} ${bad}. Website call tracking skipped.`);
169
+ const { callTracking: _drop, ...rest } = safeEntry;
170
+ safeEntry = rest;
171
+ }
172
+ }
173
+
152
174
  const category: ConsentCategory = entry.category ?? 'marketing';
153
175
  tags.push({
154
176
  key: provider,
@@ -0,0 +1,49 @@
1
+ ---
2
+ /**
3
+ * Masthead — the site's top bar, from `site.chrome.masthead` with per-layout overrides. Markup
4
+ * and classes only, no runtime JS: position, glass/transparent surfaces, inset/floating layout,
5
+ * shadow/border and hide-on-scroll are all `patterns/masthead.css` (scroll-state queries).
6
+ *
7
+ * import site from '../../site.json';
8
+ * import { resolveNavOptions } from '@getvitops/astro/components/masthead.ts';
9
+ * <Masthead config={site}>
10
+ * <a slot="brand" href="/">Acme</a>
11
+ * <SiteNav {...nav} {...resolveNavOptions(site)} />
12
+ * <a slot="actions" class="cta" href="/contact">Contact</a>
13
+ * </Masthead>
14
+ *
15
+ * A prop overrides the same field from the config for this layout — e.g. a landing page that
16
+ * sets `position="fixed" surface="transparent-until-scrolled"` over a hero. The element is a
17
+ * plain header with no `role="banner"`: a body-level header already is the banner landmark,
18
+ * and an explicit role is what auditors flag.
19
+ */
20
+ import {
21
+ mastheadClass,
22
+ mastheadRootCss,
23
+ mastheadStyle,
24
+ resolveMasthead,
25
+ type ChromeSource,
26
+ type MastheadOptions,
27
+ } from './masthead.ts';
28
+
29
+ interface Props extends MastheadOptions {
30
+ /** The site config (or anything shaped `{ site: { chrome } }`). */
31
+ config?: ChromeSource;
32
+ class?: string;
33
+ }
34
+
35
+ const { config, class: className, ...override } = Astro.props;
36
+ const masthead = resolveMasthead(config, override);
37
+ const rootCss = mastheadRootCss(masthead);
38
+ ---
39
+
40
+ {rootCss && <style is:global set:html={rootCss} />}
41
+ <header class:list={[...mastheadClass(masthead), className]} style={mastheadStyle(masthead)}>
42
+ <div class:list={['masthead__row', masthead.width === 'content' && 'centered']}>
43
+ <div class="navbar">
44
+ <slot name="brand" />
45
+ <div class="navbar__spacer"><slot /></div>
46
+ <slot name="actions" />
47
+ </div>
48
+ </div>
49
+ </header>
@@ -47,6 +47,8 @@ import {
47
47
  selectTiers,
48
48
  tierClass,
49
49
  type NavBreakpoint,
50
+ type NavCompactMode,
51
+ type NavDrawerSide,
50
52
  type NavMenuInput,
51
53
  } from './sitenav.ts';
52
54
  import SiteNavList from './SiteNavList.astro';
@@ -70,6 +72,18 @@ interface Props {
70
72
  megaBreakpoint?: NavBreakpoint;
71
73
  /** Levels in the default nav. Default 3. */
72
74
  depth?: number;
75
+ /** Levels in the mega panel. Default 3. */
76
+ megaDepth?: number;
77
+ /** What the toggle opens below the breakpoint: an edge `drawer` (default) or an anchored columnar `dropdown`. */
78
+ compact?: NavCompactMode;
79
+ /** Edge the drawer slides from. Default `end`. */
80
+ drawerSide?: NavDrawerSide;
81
+ /**
82
+ * An Astro component rendered as an extra column inside each top-level mega-tier panel, given
83
+ * `{ item }`. Kept a prop so this package has no CMS dependency (`@getvitops/emdash`'s
84
+ * `VitopsMegaPanel` fills it from a widget area). Mega tier only; hidden when narrow.
85
+ */
86
+ megaPanel?: any;
73
87
  /** Override the current path (for `aria-current`). Default: the request path. */
74
88
  current?: string;
75
89
  /** Id prefix for the drawer popovers. Required only if two SiteNavs share a page. */
@@ -87,6 +101,10 @@ const {
87
101
  mobileBreakpoint,
88
102
  megaBreakpoint,
89
103
  depth,
104
+ megaDepth,
105
+ compact,
106
+ drawerSide,
107
+ megaPanel,
90
108
  current = Astro.url.pathname,
91
109
  id = 'sitenav',
92
110
  class: className,
@@ -100,6 +118,7 @@ const tiers = selectTiers({
100
118
  mobileBreakpoint,
101
119
  megaBreakpoint,
102
120
  depth,
121
+ megaDepth,
103
122
  });
104
123
  ---
105
124
 
@@ -108,7 +127,7 @@ const tiers = selectTiers({
108
127
  const panel = drawerId(id, tier);
109
128
  return (
110
129
  <div class:list={[...tierClass(tier), className]}>
111
- <nav class:list={navClass(tier)} aria-label={label}>
130
+ <nav class:list={navClass(tier, { compact, drawerSide })} aria-label={label}>
112
131
  <button
113
132
  type="button"
114
133
  class="sitenav__toggle"
@@ -156,7 +175,7 @@ const tiers = selectTiers({
156
175
  <line x1="18" y1="6" x2="6" y2="18" />
157
176
  </svg>
158
177
  </button>
159
- <SiteNavList items={tier.items} current={current} />
178
+ <SiteNavList items={tier.items} current={current} panel={tier.kind === 'mega' ? megaPanel : undefined} />
160
179
  </div>
161
180
  </nav>
162
181
  {Astro.slots.has('actions') && (
@@ -20,9 +20,13 @@ interface Props {
20
20
  items: NavItem[];
21
21
  current: string;
22
22
  top?: boolean;
23
+ /** Mega tier only: component rendered as an extra column in each top-level submenu. */
24
+ panel?: any;
25
+ /** The branch whose submenu this is — what the `panel` column is about. */
26
+ owner?: NavItem;
23
27
  }
24
28
 
25
- const { items, current, top = true } = Astro.props;
29
+ const { items, current, top = true, panel: Panel, owner } = Astro.props;
26
30
  ---
27
31
 
28
32
  <ul class={top ? 'sitenav__list' : 'sitenav__submenu'} role="list">
@@ -63,11 +67,24 @@ const { items, current, top = true } = Astro.props;
63
67
  </span>
64
68
  </summary>
65
69
  </details>
66
- <Astro.self items={kids} current={current} top={false} />
70
+ <Astro.self
71
+ items={kids}
72
+ current={current}
73
+ top={false}
74
+ panel={top ? Panel : undefined}
75
+ owner={item}
76
+ />
67
77
  </li>
68
78
  ) : (
69
79
  <li class="sitenav__item">{link}</li>
70
80
  );
71
81
  })
72
82
  }
83
+ {
84
+ !top && Panel && owner && (
85
+ <li class="sitenav__item sitenav__mega-extra">
86
+ <Panel item={owner} />
87
+ </li>
88
+ )
89
+ }
73
90
  </ul>
@@ -16,6 +16,9 @@
16
16
  * `clearCookies()` queries `[data-consent="<category>"]` and reads
17
17
  * `data-consent-cookies` off whatever it finds, so revoking consent clears
18
18
  * `_ac`. That cleanup did not exist before.
19
+ * - **Call-tracking numbers** ride on the same marker (`data-phone-numbers`,
20
+ * `data-google-call-tracking`) when `site.tracking.phoneNumbers` is set. The
21
+ * HTML itself always carries the real number; only the script swaps it.
19
22
  * - **The script**, which captures synchronously and then waits on consent for
20
23
  * the *write* only. See src/scripts/tracking.ts.
21
24
  *
@@ -25,7 +28,8 @@
25
28
  // @ts-ignore — virtual module provided by the vitops() integration at build time
26
29
  import head from 'virtual:getvitops/head';
27
30
 
28
- const { enabled, category, cookies, abVariantCookie } = head.tracking ?? { enabled: false };
31
+ const { enabled, category, cookies, abVariantCookie, phoneNumbers, googleCallTracking } =
32
+ head.tracking ?? { enabled: false };
29
33
  ---
30
34
 
31
35
  {enabled && (
@@ -36,6 +40,8 @@ const { enabled, category, cookies, abVariantCookie } = head.tracking ?? { enabl
36
40
  data-consent={category}
37
41
  data-consent-cookies={cookies?.length ? cookies.join(',') : undefined}
38
42
  data-ab-cookie={abVariantCookie ?? undefined}
43
+ data-phone-numbers={phoneNumbers ? JSON.stringify(phoneNumbers) : undefined}
44
+ data-google-call-tracking={googleCallTracking ? '' : undefined}
39
45
  />
40
46
  <script>
41
47
  import '../scripts/tracking.ts';