/* RFC-1241 (Verbund V05 / spec v0.1.4, VID-015): first-touch UTM acquisition context for website-ingress/v1. Captures the landing `utm_*` parameters into sessionStorage on first sight (never overwrites), reads them back at form-submit time, and validates the block against the contract shape — schema const + exactly six charset-bounded keys, no extra properties. The browser is the only layer that observes `location.search`; storage is injected so the module stays runtime-agnostic and node-testable. Do not persist across sessions — sessionStorage only, never cookies or localStorage (RFC-1198 cookie-free posture; dies with the tab). Do not send partial or malformed blocks — absence means "no acquisition context", matching the hub's silent-strip semantics. Do not record platform click ids — the contract charset makes them inexpressible by design (additionalProperties: false). Do not enumerate campaign semantics — utm_* values are opaque producer data; metads owns the phase→outcome interpretation. RFC-1241: initial implementation — capture/read/validate for the optional `acquisition` field on website-ingress/v1. RFC-1241: Verbund V05 — emit acquisition-context on website-ingress (first-touch UTM) RFC-1241: sessionStorage-captured first-touch utm_* flows browser -> send-message -> IntegrationEvent.payload.acquisition -> lagebild destination adapter -> website-ingress/v1 acquisition field. Producer validates the contract shape at every edge; malformed degrades to absent, never a dispatch error. contract_version stays 1. - shared: acquisition.ts (capture/read/isValid, storage-injected) - ingress: IngressSubmitInput.acquisition -> wire field - adapter: validated pass-through of payload.acquisition - site: SendMessageRequestSchema.acquisition + client attach + api re-validation + site-wide capture mount in layout-component - codegen repin v0.1.3 -> v0.1.4 (VID-016 republication); generated types unchanged in shape, new sourceHash - conformance: emitted acquisition + emitted ingress surface validated per snapshot (82 tests, 5 snapshots) RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt */ import type { AcquisitionContext } from "./lagebild-contracts.types.generated.ts"; /** * Minimal storage surface — injected so the module never touches `window` * directly (testable in node; callers pass `sessionStorage` in the browser). */ export interface AcquisitionStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; } /** Wire charset per acquisition-context/v1 — `^[A-Za-z0-9._:-]{1,80}$`. */ export const UTM_VALUE_PATTERN = /^[A-Za-z0-9._:-]{1,80}$/; export const ACQUISITION_SCHEMA_ID = "acquisition-context/v1" as const; export const ACQUISITION_STORAGE_KEY = "wg_acquisition_v1"; /** Contract-sanctioned placeholder for a declared-but-uncaptured key (V05 handoff). */ export const ACQUISITION_MISSING_VALUE = "-"; const UTM_KEYS = [ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", ] as const; function sanitize(value: string | null): string { return value !== null && UTM_VALUE_PATTERN.test(value) ? value : ACQUISITION_MISSING_VALUE; } /** * Structural validator shared by every producer edge (api route, destination * adapter, tests): `schema` const + exactly the six `utm_*` keys, each a * charset-valid string — nothing else. */ export function isValidAcquisitionContext(value: unknown): value is AcquisitionContext { if (typeof value !== "object" || value === null) return false; const record = value as Record; if (record.schema !== ACQUISITION_SCHEMA_ID) return false; const keys = Object.keys(record); if (keys.length !== 1 + UTM_KEYS.length) return false; for (const key of UTM_KEYS) { if (typeof record[key] !== "string" || !UTM_VALUE_PATTERN.test(record[key] as string)) { return false; } } return true; } /** * First-touch capture: when the landing URL carries a charset-valid * `utm_source` and no earlier capture exists in `storage`, persists the full * block. Absent/invalid secondary keys (`utm_medium`, `utm_campaign`, * `utm_term`, `utm_content`, `utm_id`) become the `-` sentinel — the contract * requires all six keys even though real campaigns rarely send all six. * Without `utm_source` there is no acquisition context at all: nothing stored. * Existing storage content (even corrupt) is never overwritten — first-touch. */ export function captureAcquisition( search: string | URLSearchParams, storage: AcquisitionStorage, ): void { try { if (storage.getItem(ACQUISITION_STORAGE_KEY) !== null) return; const params = typeof search === "string" ? new URLSearchParams(search) : search; const source = params.get("utm_source"); if (source === null || !UTM_VALUE_PATTERN.test(source)) return; const block: AcquisitionContext = { schema: ACQUISITION_SCHEMA_ID, utm_source: source, utm_medium: sanitize(params.get("utm_medium")), utm_campaign: sanitize(params.get("utm_campaign")), utm_term: sanitize(params.get("utm_term")), utm_content: sanitize(params.get("utm_content")), utm_id: sanitize(params.get("utm_id")), }; storage.setItem(ACQUISITION_STORAGE_KEY, JSON.stringify(block)); } catch { // sessionStorage may throw (privacy mode, quota) — capture is best-effort; // a failed capture means "no acquisition" downstream, never an error. } } /** * Read back the stored block. Returns `undefined` on missing, unparseable, or * structurally invalid content — corrupt values self-heal on the next * UTM-tagged landing only when the key is absent; a corrupt entry counts as * captured (first-touch wins over self-repair). */ export function readAcquisition(storage: AcquisitionStorage): AcquisitionContext | undefined { try { const raw = storage.getItem(ACQUISITION_STORAGE_KEY); if (raw === null) return undefined; const parsed: unknown = JSON.parse(raw); return isValidAcquisitionContext(parsed) ? parsed : undefined; } catch { return undefined; } }