Add SDK client with consent cookie caching

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-15 18:12:52 +04:00
parent 5b5c671eb1
commit 5cdf39c4f7
3 changed files with 299 additions and 9 deletions

View File

@@ -0,0 +1,232 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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<string, boolean>;
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<void> {
const configUrl = `${this.baseUrl}/${this.bannerId}/config`;
const config = await fetchJSON<BannerConfig>(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<VisitorConsent>(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<ConsentRecord> {
const cfg = this.config;
const consentData: Record<string, boolean> = {};
for (const cat of cfg.categories) {
consentData[cat.name] = true;
}
return this.recordConsent("ACCEPT_ALL", consentData);
}
async rejectAll(): Promise<ConsentRecord> {
const cfg = this.config;
const consentData: Record<string, boolean> = {};
for (const cat of cfg.categories) {
consentData[cat.name] = cat.required;
}
return this.recordConsent("REJECT_ALL", consentData);
}
async customize(
categories: Record<string, boolean>,
): Promise<ConsentRecord> {
const cfg = this.config;
const consentData: Record<string, boolean> = {};
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<string, boolean>,
): Promise<ConsentRecord> {
const cfg = this.config;
const url = `${this.baseUrl}/${this.bannerId}/consents`;
const record = await fetchJSON<ConsentRecord>(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;
}
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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<string, boolean>;
}
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`;
}

View File

@@ -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<void> {
// TODO: implement
}