From 5cdf39c4f7613fc3c89c9f00839f82e2838883e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 15 Apr 2026 18:12:52 +0400 Subject: [PATCH] Add SDK client with consent cookie caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CookieBannerClient wraps the cookie banner REST API with methods to load config, accept/reject/customize consent, and manage visitor identity. Consent state is persisted in a probo_consent cookie to skip API calls on return visits, with version-aware invalidation and configurable expiry. Signed-off-by: Émile Ré --- packages/cookie-banner/src/client.ts | 232 +++++++++++++++++++++++++++ packages/cookie-banner/src/cookie.ts | 56 +++++++ packages/cookie-banner/src/index.ts | 20 +-- 3 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 packages/cookie-banner/src/client.ts create mode 100644 packages/cookie-banner/src/cookie.ts diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts new file mode 100644 index 000000000..e829e7ea4 --- /dev/null +++ b/packages/cookie-banner/src/client.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { getConsentCookie, setConsentCookie } from "./cookie"; +import { NotFoundError } from "./errors"; +import { fetchJSON } from "./http"; + +export interface CookieItem { + name: string; + duration: string; + description: string; +} + +export interface Category { + name: string; + description: string; + required: boolean; + cookies: CookieItem[]; +} + +export interface BannerConfig { + banner_id: string; + version: number; + privacy_policy_url: string; + consent_expiry_days: number; + consent_mode: "OPT_IN" | "OPT_OUT"; + categories: Category[]; +} + +export type ConsentAction = "ACCEPT_ALL" | "REJECT_ALL" | "CUSTOMIZE" | "GPC"; + +export interface VisitorConsent { + visitor_id: string; + version: number; + action: ConsentAction; + consent_data: Record; + created_at: string; +} + +export interface ConsentRecord { + id: string; + visitor_id: string; + action: string; + created_at: string; +} + +export interface CookieBannerClientOptions { + bannerId: string; + baseUrl: string; +} + +const STORAGE_KEY_PREFIX = "probo_consent"; + +function getOrCreateVisitorId(bannerId: string): string { + const key = `${STORAGE_KEY_PREFIX}:${bannerId}:vid`; + + try { + const stored = localStorage.getItem(key); + if (stored) { + return stored; + } + } catch { + // localStorage unavailable + } + + const id = crypto.randomUUID(); + + try { + localStorage.setItem(key, id); + } catch { + // localStorage unavailable + } + + return id; +} + +export class CookieBannerClient { + private readonly baseUrl: string; + private readonly bannerId: string; + private readonly visitorId: string; + + private bannerConfig: BannerConfig | null = null; + private consent: VisitorConsent | null = null; + + constructor(config: CookieBannerClientOptions) { + this.baseUrl = config.baseUrl.replace(/\/+$/, ""); + this.bannerId = config.bannerId; + this.visitorId = getOrCreateVisitorId(config.bannerId); + } + + async load(): Promise { + const configUrl = `${this.baseUrl}/${this.bannerId}/config`; + const config = await fetchJSON(configUrl); + this.bannerConfig = config; + + const cookie = getConsentCookie(); + if (cookie && cookie.v === config.version && cookie.vid === this.visitorId) { + this.consent = { + visitor_id: cookie.vid, + version: cookie.v, + action: cookie.action, + consent_data: cookie.data, + created_at: "", + }; + return; + } + + const consentUrl = `${this.baseUrl}/${this.bannerId}/consents/${this.visitorId}`; + const apiConsent = await fetchJSON(consentUrl).catch( + (err) => { + if (err instanceof NotFoundError) { + return null; + } + throw err; + }, + ); + + if (apiConsent && apiConsent.version === config.version) { + this.consent = apiConsent; + setConsentCookie( + { + v: apiConsent.version, + vid: apiConsent.visitor_id, + action: apiConsent.action, + data: apiConsent.consent_data, + }, + config.consent_expiry_days, + ); + } else { + this.consent = null; + } + } + + get config(): BannerConfig { + if (!this.bannerConfig) { + throw new Error("CookieBannerClient not loaded: call load() first"); + } + return this.bannerConfig; + } + + get visitorConsent(): VisitorConsent | null { + return this.consent; + } + + get hasConsent(): boolean { + return this.consent !== null; + } + + async acceptAll(): Promise { + const cfg = this.config; + + const consentData: Record = {}; + for (const cat of cfg.categories) { + consentData[cat.name] = true; + } + + return this.recordConsent("ACCEPT_ALL", consentData); + } + + async rejectAll(): Promise { + const cfg = this.config; + + const consentData: Record = {}; + for (const cat of cfg.categories) { + consentData[cat.name] = cat.required; + } + + return this.recordConsent("REJECT_ALL", consentData); + } + + async customize( + categories: Record, + ): Promise { + const cfg = this.config; + + const consentData: Record = {}; + for (const cat of cfg.categories) { + consentData[cat.name] = cat.required || !!categories[cat.name]; + } + + return this.recordConsent("CUSTOMIZE", consentData); + } + + private async recordConsent( + action: ConsentAction, + consentData: Record, + ): Promise { + const cfg = this.config; + const url = `${this.baseUrl}/${this.bannerId}/consents`; + + const record = await fetchJSON(url, { + method: "POST", + body: { + visitor_id: this.visitorId, + version: cfg.version, + action, + consent_data: consentData, + }, + }); + + this.consent = { + visitor_id: this.visitorId, + version: cfg.version, + action, + consent_data: consentData, + created_at: record.created_at, + }; + + setConsentCookie( + { + v: cfg.version, + vid: this.visitorId, + action, + data: consentData, + }, + cfg.consent_expiry_days, + ); + + return record; + } +} diff --git a/packages/cookie-banner/src/cookie.ts b/packages/cookie-banner/src/cookie.ts new file mode 100644 index 000000000..28a20f860 --- /dev/null +++ b/packages/cookie-banner/src/cookie.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { ConsentAction } from "./client"; + +const COOKIE_NAME = "probo_consent"; +const SECONDS_PER_DAY = 86400; + +export interface ConsentCookie { + v: number; + vid: string; + action: ConsentAction; + data: Record; +} + +export function getConsentCookie(): ConsentCookie | null { + try { + const prefix = `${COOKIE_NAME}=`; + const entry = document.cookie + .split("; ") + .find((c) => c.startsWith(prefix)); + + if (!entry) { + return null; + } + + return JSON.parse(decodeURIComponent(entry.substring(prefix.length))); + } catch { + return null; + } +} + +export function setConsentCookie( + value: ConsentCookie, + expiryDays: number, +): void { + const maxAge = expiryDays * SECONDS_PER_DAY; + const encoded = encodeURIComponent(JSON.stringify(value)); + + document.cookie = `${COOKIE_NAME}=${encoded}; path=/; max-age=${maxAge}; SameSite=Lax`; +} + +export function clearConsentCookie(): void { + document.cookie = `${COOKIE_NAME}=; path=/; max-age=0; SameSite=Lax`; +} diff --git a/packages/cookie-banner/src/index.ts b/packages/cookie-banner/src/index.ts index 6f248ef07..bf1456402 100644 --- a/packages/cookie-banner/src/index.ts +++ b/packages/cookie-banner/src/index.ts @@ -14,6 +14,17 @@ export const VERSION = "0.0.0"; +export { CookieBannerClient } from "./client"; +export type { + BannerConfig, + Category, + ConsentAction, + ConsentRecord, + CookieBannerClientOptions, + CookieItem, + VisitorConsent, +} from "./client"; +export type { ConsentCookie } from "./cookie"; export { ApiError, BadRequestError, @@ -24,12 +35,3 @@ export { } from "./errors"; export { fetchJSON } from "./http"; export type { RequestOptions } from "./http"; - -export type CookieBannerConfig = { - bannerId: string; - baseUrl: string; -}; - -export async function init(_config: CookieBannerConfig): Promise { - // TODO: implement -}