From 126ca62fa2ff25b21642eb555629e85181aa7948 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 5 Oct 2026 11:50:23 +0100 Subject: [PATCH 1/3] feat(signup): remember a Dr Green ?ref= landing code in the bs_ref cookie (BS-A01) Middleware adds a 30-day HttpOnly SameSite=Lax first-party cookie to storefront page responses when ?ref= matches Dr Green's code format (upper-cased; malformed values set nothing). /register keeps its query string on the redirect to /consultation. bs_ref is listed as an essential storefront cookie in lib/cookie-utils.ts. PRD copied to tasks/. --- .../app/store/[slug]/register/page.tsx | 8 +- nextjs_space/lib/affiliate/affiliate-code.ts | 70 +++++++++++ nextjs_space/lib/affiliate/referral-cookie.ts | 64 ++++++++++ nextjs_space/lib/cookie-utils.ts | 40 ++++++ nextjs_space/middleware.ts | 26 +++- .../unit/affiliate-referral-cookie.test.ts | 114 ++++++++++++++++++ tasks/prd-drgreen-affiliate-codes.md | 77 ++++++++++++ 7 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 nextjs_space/lib/affiliate/affiliate-code.ts create mode 100644 nextjs_space/lib/affiliate/referral-cookie.ts create mode 100644 nextjs_space/tests/unit/affiliate-referral-cookie.test.ts create mode 100644 tasks/prd-drgreen-affiliate-codes.md diff --git a/nextjs_space/app/store/[slug]/register/page.tsx b/nextjs_space/app/store/[slug]/register/page.tsx index 436e855d..4de10341 100644 --- a/nextjs_space/app/store/[slug]/register/page.tsx +++ b/nextjs_space/app/store/[slug]/register/page.tsx @@ -9,7 +9,7 @@ import { getTenantBasePath } from "@/lib/tenant/tenant-utils"; * Registration Redirect * * All customer signups must go through the consultation form for KYC compliance. - * This page redirects to the consultation page. + * This page redirects to the consultation page, query string included. */ export default function RegisterRedirectPage() { const params = useParams(); @@ -19,7 +19,11 @@ export default function RegisterRedirectPage() { useEffect(() => { if (slug) { const basePath = getTenantBasePath(slug); - router.replace(`${basePath}/consultation`); + // BS-A01: keep the query string so a holder's /register?ref=CODE link + // still pre-fills the referral code. Read from window.location (this + // effect only runs in the browser) rather than useSearchParams, which + // would need a Suspense boundary around this page. + router.replace(`${basePath}/consultation${window.location.search}`); } }, [slug, router]); diff --git a/nextjs_space/lib/affiliate/affiliate-code.ts b/nextjs_space/lib/affiliate/affiliate-code.ts new file mode 100644 index 00000000..1c60c6ff --- /dev/null +++ b/nextjs_space/lib/affiliate/affiliate-code.ts @@ -0,0 +1,70 @@ +/** + * Dr Green affiliate codes — the BudStacks capture side (BS-A01..A03, + * tasks/prd-drgreen-affiliate-codes.md). Dr Green defines the code (its + * US-A02) and decides what it links to (US-A04); BudStacks only remembers the + * code a visitor arrived with, lets them type one, and forwards it. + * + * A code never changes a price and never blocks a sign-up. + * + * Pure and runtime-agnostic: imported by middleware (edge), the consultation + * page and route (node) and the sign-up form (browser). + */ + +/** + * Dr Green US-A02: 4–20 characters, letters, digits and hyphens, starting and + * ending with a letter or digit. Stored upper-case, matched case-insensitively. + */ +export const AFFILIATE_CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]{2,18}[A-Za-z0-9]$/; +export const AFFILIATE_CODE_MAX_LENGTH = 20; + +/** The query parameter a holder's share link carries (`?ref=CODE`). */ +export const REFERRAL_QUERY_PARAM = "ref"; + +/** First-party landing cookie — strictly functional (lib/cookie-utils.ts). */ +export const REFERRAL_COOKIE_NAME = "bs_ref"; +export const REFERRAL_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; + +/** Sent to Dr Green as `affiliateCodeSource` (US-A04). */ +export const AFFILIATE_CODE_SOURCE = { + LINK: "link", + TYPED: "typed", +} as const; + +export type AffiliateCodeSource = + (typeof AFFILIATE_CODE_SOURCE)[keyof typeof AFFILIATE_CODE_SOURCE]; + +/** Field copy for the sign-up forms. */ +export const AFFILIATE_CODE_LABEL = "Referral code (optional)"; +export const AFFILIATE_CODE_HELP = "Does not change any price."; +export const AFFILIATE_CODE_FORMAT_ERROR = + "Referral codes are 4 to 20 letters, numbers or hyphens. Check the code, or leave the field empty."; + +/** True when `value` is a well-formed code (case-insensitive). */ +export function isAffiliateCodeFormat(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= AFFILIATE_CODE_MAX_LENGTH && + AFFILIATE_CODE_PATTERN.test(value) + ); +} + +/** + * The canonical (trimmed, upper-cased) code, or null for anything absent or + * malformed. Malformed input is never an error at this layer: it is simply + * not a code. + */ +export function normaliseAffiliateCode(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return isAffiliateCodeFormat(trimmed) ? trimmed.toUpperCase() : null; +} + +/** + * Inline field error for the sign-up forms: null when the field is empty (it + * is optional) or well-formed, the format message otherwise. + */ +export function affiliateCodeFieldError(value: string | null | undefined): string | null { + const trimmed = (value ?? "").trim(); + if (trimmed === "") return null; + return isAffiliateCodeFormat(trimmed) ? null : AFFILIATE_CODE_FORMAT_ERROR; +} diff --git a/nextjs_space/lib/affiliate/referral-cookie.ts b/nextjs_space/lib/affiliate/referral-cookie.ts new file mode 100644 index 00000000..1a5a9d76 --- /dev/null +++ b/nextjs_space/lib/affiliate/referral-cookie.ts @@ -0,0 +1,64 @@ +/** + * The `bs_ref` landing cookie (BS-A01): written by middleware when a storefront + * URL carries a well-formed `?ref=`, read by the consultation page (pre-fill) + * and the consultation submit route (link vs typed), cleared by that route on + * a successful sign-up (BS-A02). + * + * Host-only (no Domain attribute): it lives on the storefront host the visitor + * landed on — subdomain or custom domain — which is the same host the sign-up + * form posts to. HttpOnly, so the form never reads it; the page passes the + * value down as an initial value instead. + * + * Edge-safe: middleware imports this. + */ +import type { NextResponse } from "next/server"; +import { + REFERRAL_COOKIE_MAX_AGE_SECONDS, + REFERRAL_COOKIE_NAME, + normaliseAffiliateCode, +} from "./affiliate-code"; + +type ResponseWithCookies = Pick; + +// Secure in production, as the impersonation cookie does; plain-http local dev +// (Safari refuses Secure cookies on http://localhost) would otherwise drop it. +function baseCookieOptions() { + return { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + }; +} + +/** + * Remember a landing code. A malformed or absent `ref` sets nothing and leaves + * any earlier cookie alone; a well-formed one is stored upper-cased and + * restarts the 30 days (the latest link the visitor followed wins). + * Returns whether a cookie was written. + */ +export function setReferralCookie( + response: ResponseWithCookies, + ref: string | null | undefined, +): boolean { + const code = normaliseAffiliateCode(ref); + if (!code) return false; + response.cookies.set(REFERRAL_COOKIE_NAME, code, { + ...baseCookieOptions(), + maxAge: REFERRAL_COOKIE_MAX_AGE_SECONDS, + }); + return true; +} + +/** Expire the landing cookie — called once the sign-up has succeeded. */ +export function clearReferralCookie(response: ResponseWithCookies): void { + response.cookies.set(REFERRAL_COOKIE_NAME, "", { + ...baseCookieOptions(), + maxAge: 0, + }); +} + +/** The remembered code from a cookie value, or null when absent or tampered. */ +export function readReferralCookie(value: string | null | undefined): string | null { + return normaliseAffiliateCode(value); +} diff --git a/nextjs_space/lib/cookie-utils.ts b/nextjs_space/lib/cookie-utils.ts index af87e330..11e13543 100644 --- a/nextjs_space/lib/cookie-utils.ts +++ b/nextjs_space/lib/cookie-utils.ts @@ -5,6 +5,8 @@ * Provides region-based consent logic for GDPR, CCPA, POPIA compliance */ +import { REFERRAL_COOKIE_NAME } from "@/lib/affiliate/affiliate-code"; + // EU countries requiring GDPR opt-in consent const EU_COUNTRIES = [ "AT", @@ -148,6 +150,44 @@ export function getRegionBannerText(countryCode: string | null | undefined): { export const CONSENT_COOKIE_NAME = "budstack_cookie_consent"; export const CONSENT_CATEGORIES_COOKIE_NAME = "budstack_cookie_categories"; +export interface CookieDescriptor { + name: string; + category: keyof ConsentCategories; + purpose: string; + duration: string; +} + +/** + * First-party cookies BudStacks itself sets on a storefront, by consent + * category. `essential` ones are set without asking (they are needed for + * something the visitor started) and are never gated on the banner. + */ +export const STOREFRONT_COOKIES: readonly CookieDescriptor[] = [ + { + name: CONSENT_COOKIE_NAME, + category: "essential", + purpose: "Records that you have answered the cookie banner.", + duration: "1 year", + }, + { + name: CONSENT_CATEGORIES_COOKIE_NAME, + category: "essential", + purpose: "Records which cookie categories you accepted.", + duration: "1 year", + }, + { + // BS-A01 (Dr Green affiliate codes): set by middleware from a valid + // `?ref=` so the sign-up the visitor started can carry the code they + // arrived with. Never read by script (HttpOnly), never changes a price, + // cleared once sign-up succeeds. + name: REFERRAL_COOKIE_NAME, + category: "essential", + purpose: + "Remembers the referral code in the link you followed, so the sign-up you start can include it. Cleared when you sign up.", + duration: "30 days", + }, +]; + /** * Default consent categories based on consent model */ diff --git a/nextjs_space/middleware.ts b/nextjs_space/middleware.ts index 19fcbb59..0b7c4ac6 100644 --- a/nextjs_space/middleware.ts +++ b/nextjs_space/middleware.ts @@ -4,6 +4,8 @@ import { parseHostToTenantHint, wwwRedirectHost } from "@/lib/parse-host"; import { customDomainRewritePath } from "@/lib/custom-domain-rewrite"; import { resolvePlatformRedirect, resolveStoreRedirect } from "@/lib/seo/redirect-lookup"; import { applyCsp, buildCsp, generateNonce, variantForServedPath } from "@/lib/security/csp"; +import { REFERRAL_QUERY_PARAM } from "@/lib/affiliate/affiliate-code"; +import { setReferralCookie } from "@/lib/affiliate/referral-cookie"; // Define public routes const isPublicRoute = createRouteMatcher([ @@ -109,6 +111,16 @@ function resolveTenantHost(req: NextRequest): string { : req.headers.get('host') || ''; } +// BS-A01 (Dr Green affiliate codes): a storefront PAGE response remembers a +// well-formed `?ref=CODE` in the first-party `bs_ref` cookie (30 days). Only a +// Set-Cookie header is added — the rewrite target, headers and CSP are the +// ones the caller built, so tenant/host routing is untouched. Malformed values +// set nothing. API routes, admin redirects and /auth never get here. +function withReferralCookie(res: T, req: NextRequest): T { + setReferralCookie(res, req.nextUrl.searchParams.get(REFERRAL_QUERY_PARAM)); + return res; +} + const clerkHandler = clerkMiddleware(async (auth, req) => { // 1. Tenant Routing Logic (must run BEFORE auth check) // Subdomain rewrites change /products → /store/slug/products which matches @@ -250,7 +262,10 @@ const clerkHandler = clerkMiddleware(async (auth, req) => { // Page routes: rewrite to internal store route url.pathname = `/store/${subdomain}${pathname}`; - return applyCsp(NextResponse.rewrite(url, { request: { headers: requestHeaders } }), nonce, "store"); + return withReferralCookie( + applyCsp(NextResponse.rewrite(url, { request: { headers: requestHeaders } }), nonce, "store"), + req, + ); } // PRIORITY 2: Custom domain routing (REWRITE) @@ -304,7 +319,10 @@ const clerkHandler = clerkMiddleware(async (auth, req) => { // (PRD-212). hint.host is the real custom domain; the derived cd- // segment isolates this domain's ISR cache from every other custom domain. url.pathname = customDomainRewritePath(hint.host, pathname); - return applyCsp(NextResponse.rewrite(url, { request: { headers: requestHeaders } }), nonce, "store"); + return withReferralCookie( + applyCsp(NextResponse.rewrite(url, { request: { headers: requestHeaders } }), nonce, "store"), + req, + ); } // 2. Authentication Check (only for non-subdomain, non-custom-domain requests) @@ -325,11 +343,13 @@ const clerkHandler = clerkMiddleware(async (auth, req) => { // All requests forward with the nonce + per-request CSP. The static // next.config.js CSP was removed (PRD-218) — every response must carry the // policy from here so no page renders without it. - return applyCsp( + const response = applyCsp( NextResponse.next({ request: { headers: requestHeaders } }), nonce, variantForServedPath(pathname), ); + // Path-based storefronts (/store//…, localhost) remember ?ref= too. + return storeMatch ? withReferralCookie(response, req) : response; }); // Clerk derives every absolute URL it builds — most visibly the dev-browser diff --git a/nextjs_space/tests/unit/affiliate-referral-cookie.test.ts b/nextjs_space/tests/unit/affiliate-referral-cookie.test.ts new file mode 100644 index 00000000..602a92ab --- /dev/null +++ b/nextjs_space/tests/unit/affiliate-referral-cookie.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { NextResponse } from "next/server"; +import { + AFFILIATE_CODE_FORMAT_ERROR, + REFERRAL_COOKIE_MAX_AGE_SECONDS, + REFERRAL_COOKIE_NAME, + affiliateCodeFieldError, + isAffiliateCodeFormat, + normaliseAffiliateCode, +} from "@/lib/affiliate/affiliate-code"; +import { + clearReferralCookie, + readReferralCookie, + setReferralCookie, +} from "@/lib/affiliate/referral-cookie"; +import { STOREFRONT_COOKIES } from "@/lib/cookie-utils"; + +/** + * BS-A01 — the landing code matcher and the `bs_ref` cookie. The pattern is + * Dr Green's (US-A02), so these cases are the contract both sides share. + */ +describe("affiliate code matcher (Dr Green US-A02 format)", () => { + it.each([ + "ABCD", + "abcd", + "TEST-CODE", + "a1-b2-c3", + "12345678901234567890", // 20 = max + "A--B", + ])("accepts %s", (code) => { + expect(isAffiliateCodeFormat(code)).toBe(true); + }); + + it.each([ + "", + "ABC", // 3 < min 4 + "123456789012345678901", // 21 > max 20 + "-ABCD", // leading hyphen + "ABCD-", // trailing hyphen + "AB CD", // space + "AB_CD", // underscore + "AB/CD", // slash (the WordPress json_encode trap) + "ÄBCD", // non-ASCII letter + "ABCD\n", + ])("rejects %j", (code) => { + expect(isAffiliateCodeFormat(code)).toBe(false); + }); + + it("rejects non-strings", () => { + expect(isAffiliateCodeFormat(undefined)).toBe(false); + expect(isAffiliateCodeFormat(null)).toBe(false); + expect(isAffiliateCodeFormat(1234)).toBe(false); + }); + + it("normalises to trimmed upper-case, null when malformed", () => { + expect(normaliseAffiliateCode(" test-code ")).toBe("TEST-CODE"); + expect(normaliseAffiliateCode("AB")).toBeNull(); + expect(normaliseAffiliateCode(undefined)).toBeNull(); + }); + + it("gives a field error only for a non-empty malformed value", () => { + expect(affiliateCodeFieldError("")).toBeNull(); + expect(affiliateCodeFieldError(" ")).toBeNull(); + expect(affiliateCodeFieldError(undefined)).toBeNull(); + expect(affiliateCodeFieldError("TEST-CODE")).toBeNull(); + expect(affiliateCodeFieldError("AB")).toBe(AFFILIATE_CODE_FORMAT_ERROR); + }); +}); + +describe("bs_ref landing cookie", () => { + it("stores a well-formed ref upper-cased for 30 days, HttpOnly, Lax, path /", () => { + const res = NextResponse.json({}); + expect(setReferralCookie(res, "test-code")).toBe(true); + + const cookie = res.cookies.get(REFERRAL_COOKIE_NAME); + expect(cookie?.value).toBe("TEST-CODE"); + expect(cookie?.maxAge).toBe(REFERRAL_COOKIE_MAX_AGE_SECONDS); + expect(REFERRAL_COOKIE_MAX_AGE_SECONDS).toBe(30 * 24 * 60 * 60); + expect(cookie?.httpOnly).toBe(true); + expect(cookie?.sameSite).toBe("lax"); + expect(cookie?.path).toBe("/"); + }); + + it.each([null, undefined, "", "AB", "