diff --git a/docs/GDPR_STORAGE_CLASSES.md b/docs/GDPR_STORAGE_CLASSES.md
new file mode 100644
index 00000000..bf066e61
--- /dev/null
+++ b/docs/GDPR_STORAGE_CLASSES.md
@@ -0,0 +1,64 @@
+# GDPR Storage Classes
+
+## Problem
+
+The cookie consent store (`src/lib/consent/store.ts`) already tracks which
+consent categories (`necessary`, `analytics`, `functional`, `marketing`) a
+user has accepted. Nothing, however, tied that decision to the actual
+browser storage (`localStorage`, `sessionStorage`, cookies) the app writes.
+A feature could write an analytics identifier to `localStorage` regardless
+of the user's choice, and revoking a previously granted category didn't
+clear anything that had already been written — both of which are GDPR
+gaps (Art. 7(3): withdrawing consent must be as easy, and as effective, as
+giving it).
+
+## Solution: Storage Classes
+
+`src/lib/consent/storageClasses.ts` introduces a small enforcement layer on
+top of the existing consent store:
+
+- **Declare** which consent category a storage key belongs to via a
+ `StorageClassDescriptor { key, category, area }`.
+- **Gate** reads/writes: `setClassifiedItem`/`getClassifiedItem` only touch
+ storage when the descriptor's category is currently consented to.
+- **Purge**: `purgeDisallowedStorage()` removes any registered entry whose
+ category is no longer allowed. `enforceStorageClasses()` runs a purge
+ immediately and again on every consent change (via
+ `useConsentStore.subscribe`).
+
+### Usage
+
+```ts
+import { setClassifiedItem, getClassifiedItem } from '@/lib/consent/storageClasses';
+
+// Only written if the user has consented to "analytics".
+setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, clientId);
+
+// Returns null if the category isn't consented to, even if a stale value
+// is still physically present.
+const clientId = getClassifiedItem('ga_client_id');
+```
+
+Supported `area` values: `localStorage`, `sessionStorage`, `cookie`.
+
+### Enforcement
+
+`useStorageClassEnforcement()` is called once from
+`src/components/consent/CookieConsentBanner.tsx`, which is always mounted
+(see `RootProviders`). This means:
+
+- On app load, any storage left over from a category the user has since
+ revoked (e.g. across browser sessions) is purged immediately.
+- Whenever the user changes their preferences in `CookiePreferencesModal`
+ (accept all / reject all / save custom preferences), any storage tied to
+ a category that's no longer allowed is purged automatically.
+
+### Testing
+
+- `src/lib/consent/__tests__/storageClasses.test.ts` — unit tests for
+ registration, gating, purging, and the enforcement subscription across
+ all three storage areas.
+- `src/components/consent/__tests__/CookieConsentBanner.test.tsx` — verifies
+ the banner purges disallowed storage on mount.
+
+Run: `pnpm test src/lib/consent src/components/consent`
diff --git a/src/components/consent/CookieConsentBanner.tsx b/src/components/consent/CookieConsentBanner.tsx
index a39fa2de..7b7c0579 100644
--- a/src/components/consent/CookieConsentBanner.tsx
+++ b/src/components/consent/CookieConsentBanner.tsx
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useConsentStore } from '@/lib/consent/store';
+import { useStorageClassEnforcement } from '@/lib/consent/storageClasses';
import { CookiePreferencesModal } from './CookiePreferencesModal';
/**
@@ -16,6 +17,10 @@ export function CookieConsentBanner() {
const rejectAll = useConsentStore((s) => s.rejectAll);
const [showPreferences, setShowPreferences] = useState(false);
+ // Purge any storage classes the user isn't (or is no longer) consenting to.
+ // Runs for the lifetime of the app since this component is always mounted.
+ useStorageClassEnforcement();
+
// Hide banner once a valid decision exists
if (decided && isConsentValid()) return null;
diff --git a/src/components/consent/__tests__/CookieConsentBanner.test.tsx b/src/components/consent/__tests__/CookieConsentBanner.test.tsx
new file mode 100644
index 00000000..7d8bb645
--- /dev/null
+++ b/src/components/consent/__tests__/CookieConsentBanner.test.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { CookieConsentBanner } from '../CookieConsentBanner';
+import { useConsentStore } from '@/lib/consent/store';
+import { createDefaultConsentState } from '@/lib/consent/types';
+import { clearStorageClassRegistry, setClassifiedItem } from '@/lib/consent/storageClasses';
+
+beforeEach(() => {
+ localStorage.clear();
+ useConsentStore.setState(createDefaultConsentState());
+ clearStorageClassRegistry();
+});
+
+describe('CookieConsentBanner storage class enforcement', () => {
+ it('purges storage for a category that was revoked before the banner mounted', () => {
+ useConsentStore.getState().acceptAll();
+ setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123');
+ useConsentStore.getState().rejectAll();
+ expect(localStorage.getItem('ga_client_id')).toBe('123');
+
+ render();
+
+ expect(localStorage.getItem('ga_client_id')).toBeNull();
+ });
+
+ it('still renders the banner normally while enforcing storage classes', () => {
+ render();
+ expect(screen.getByRole('region', { name: /cookie consent/i })).toBeInTheDocument();
+ });
+});
diff --git a/src/lib/consent/__tests__/storageClasses.test.ts b/src/lib/consent/__tests__/storageClasses.test.ts
new file mode 100644
index 00000000..cacc4fd3
--- /dev/null
+++ b/src/lib/consent/__tests__/storageClasses.test.ts
@@ -0,0 +1,224 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { useConsentStore } from '../store';
+import { createDefaultConsentState } from '../types';
+import {
+ clearStorageClassRegistry,
+ enforceStorageClasses,
+ getClassifiedItem,
+ getStorageClass,
+ isStorageClassAllowed,
+ listRegisteredStorageKeys,
+ purgeDisallowedStorage,
+ registerStorageKey,
+ removeClassifiedItem,
+ setClassifiedItem,
+ unregisterStorageKey,
+} from '../storageClasses';
+
+function clearAllCookies() {
+ document.cookie.split(';').forEach((entry) => {
+ const name = entry.split('=')[0]?.trim();
+ if (name) document.cookie = `${name}=; path=/; max-age=0`;
+ });
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ clearAllCookies();
+ useConsentStore.setState(createDefaultConsentState());
+ clearStorageClassRegistry();
+});
+
+describe('storage key registry', () => {
+ it('registers and retrieves a descriptor', () => {
+ registerStorageKey({ key: 'foo', category: 'analytics', area: 'localStorage' });
+ expect(getStorageClass('foo')).toEqual({ key: 'foo', category: 'analytics', area: 'localStorage' });
+ });
+
+ it('returns undefined for an unregistered key', () => {
+ expect(getStorageClass('missing')).toBeUndefined();
+ });
+
+ it('unregisterStorageKey removes a descriptor', () => {
+ registerStorageKey({ key: 'foo', category: 'analytics', area: 'localStorage' });
+ unregisterStorageKey('foo');
+ expect(getStorageClass('foo')).toBeUndefined();
+ });
+
+ it('listRegisteredStorageKeys lists everything registered', () => {
+ registerStorageKey({ key: 'a', category: 'analytics', area: 'localStorage' });
+ registerStorageKey({ key: 'b', category: 'marketing', area: 'cookie' });
+ expect(listRegisteredStorageKeys()).toHaveLength(2);
+ });
+
+ it('clearStorageClassRegistry forgets everything', () => {
+ registerStorageKey({ key: 'foo', category: 'analytics', area: 'localStorage' });
+ clearStorageClassRegistry();
+ expect(listRegisteredStorageKeys()).toHaveLength(0);
+ });
+});
+
+describe('isStorageClassAllowed', () => {
+ it('always allows the necessary category', () => {
+ expect(isStorageClassAllowed('necessary')).toBe(true);
+ });
+
+ it('disallows optional categories before a decision is made', () => {
+ expect(isStorageClassAllowed('analytics')).toBe(false);
+ expect(isStorageClassAllowed('functional')).toBe(false);
+ expect(isStorageClassAllowed('marketing')).toBe(false);
+ });
+
+ it('allows a category once accepted', () => {
+ useConsentStore.getState().acceptAll();
+ expect(isStorageClassAllowed('analytics')).toBe(true);
+ expect(isStorageClassAllowed('marketing')).toBe(true);
+ });
+
+ it('disallows a category after it is explicitly rejected', () => {
+ useConsentStore.getState().acceptAll();
+ useConsentStore.getState().rejectAll();
+ expect(isStorageClassAllowed('analytics')).toBe(false);
+ });
+});
+
+describe('setClassifiedItem / getClassifiedItem', () => {
+ it('blocks localStorage writes for a non-consented category', () => {
+ const ok = setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123');
+ expect(ok).toBe(false);
+ expect(localStorage.getItem('ga_client_id')).toBeNull();
+ });
+
+ it('always allows writes for the necessary category', () => {
+ const ok = setClassifiedItem({ key: 'session_id', category: 'necessary', area: 'localStorage' }, 'abc');
+ expect(ok).toBe(true);
+ expect(localStorage.getItem('session_id')).toBe('abc');
+ });
+
+ it('allows writes once the category is consented to', () => {
+ useConsentStore.getState().savePreferences({ analytics: true });
+ const ok = setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123');
+ expect(ok).toBe(true);
+ expect(localStorage.getItem('ga_client_id')).toBe('123');
+ });
+
+ it('round-trips sessionStorage entries', () => {
+ useConsentStore.getState().savePreferences({ functional: true });
+ setClassifiedItem({ key: 'ui_layout', category: 'functional', area: 'sessionStorage' }, 'grid');
+ expect(getClassifiedItem('ui_layout')).toBe('grid');
+ expect(sessionStorage.getItem('ui_layout')).toBe('grid');
+ });
+
+ it('round-trips cookie entries', () => {
+ useConsentStore.getState().savePreferences({ marketing: true });
+ setClassifiedItem({ key: 'ad_id', category: 'marketing', area: 'cookie' }, 'xyz');
+ expect(getClassifiedItem('ad_id')).toBe('xyz');
+ expect(document.cookie).toContain('ad_id=xyz');
+ });
+
+ it('registers the descriptor even when the write is blocked', () => {
+ setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123');
+ expect(getStorageClass('ga_client_id')).toEqual({
+ key: 'ga_client_id',
+ category: 'analytics',
+ area: 'localStorage',
+ });
+ });
+
+ it('returns null for an unregistered key', () => {
+ expect(getClassifiedItem('unknown')).toBeNull();
+ });
+
+ it('returns null once consent is withdrawn, even if the raw value is still present', () => {
+ useConsentStore.getState().savePreferences({ analytics: true });
+ setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '123');
+
+ useConsentStore.getState().savePreferences({ analytics: false });
+
+ expect(getClassifiedItem('ga_client_id')).toBeNull();
+ // the raw value is untouched until a purge runs
+ expect(localStorage.getItem('ga_client_id')).toBe('123');
+ });
+});
+
+describe('removeClassifiedItem', () => {
+ it('removes a registered entry from its underlying storage', () => {
+ setClassifiedItem({ key: 'session_id', category: 'necessary', area: 'localStorage' }, 'abc');
+ removeClassifiedItem('session_id');
+ expect(localStorage.getItem('session_id')).toBeNull();
+ });
+
+ it('is a no-op for an unregistered key', () => {
+ expect(() => removeClassifiedItem('unknown')).not.toThrow();
+ });
+});
+
+describe('purgeDisallowedStorage', () => {
+ it('removes localStorage, sessionStorage, and cookie entries for revoked categories, keeping necessary/allowed ones', () => {
+ useConsentStore.getState().acceptAll();
+ setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '1');
+ setClassifiedItem({ key: 'ui_layout', category: 'functional', area: 'sessionStorage' }, 'grid');
+ setClassifiedItem({ key: 'ad_id', category: 'marketing', area: 'cookie' }, 'x');
+ setClassifiedItem({ key: 'session_id', category: 'necessary', area: 'localStorage' }, 'abc');
+
+ useConsentStore.getState().rejectAll();
+ const purged = purgeDisallowedStorage();
+
+ expect([...purged].sort()).toEqual(['ad_id', 'ga_client_id', 'ui_layout']);
+ expect(localStorage.getItem('ga_client_id')).toBeNull();
+ expect(sessionStorage.getItem('ui_layout')).toBeNull();
+ expect(document.cookie).not.toContain('ad_id=');
+ expect(localStorage.getItem('session_id')).toBe('abc');
+ });
+
+ it('is a no-op when nothing is registered', () => {
+ expect(purgeDisallowedStorage()).toEqual([]);
+ });
+
+ it('is a no-op when every registered category is still allowed', () => {
+ useConsentStore.getState().acceptAll();
+ setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '1');
+ expect(purgeDisallowedStorage()).toEqual([]);
+ expect(localStorage.getItem('ga_client_id')).toBe('1');
+ });
+});
+
+describe('enforceStorageClasses', () => {
+ it('purges already-disallowed entries immediately on start', () => {
+ useConsentStore.getState().acceptAll();
+ setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '1');
+ useConsentStore.getState().rejectAll();
+ expect(localStorage.getItem('ga_client_id')).toBe('1'); // stale until enforcement starts
+
+ const unsubscribe = enforceStorageClasses();
+
+ expect(localStorage.getItem('ga_client_id')).toBeNull();
+ unsubscribe();
+ });
+
+ it('re-purges automatically whenever consent changes', () => {
+ const unsubscribe = enforceStorageClasses();
+
+ useConsentStore.getState().savePreferences({ analytics: true });
+ setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '2');
+ expect(localStorage.getItem('ga_client_id')).toBe('2');
+
+ useConsentStore.getState().rejectAll();
+ expect(localStorage.getItem('ga_client_id')).toBeNull();
+
+ unsubscribe();
+ });
+
+ it('stops purging after unsubscribe is called', () => {
+ const unsubscribe = enforceStorageClasses();
+ unsubscribe();
+
+ useConsentStore.getState().savePreferences({ analytics: true });
+ setClassifiedItem({ key: 'ga_client_id', category: 'analytics', area: 'localStorage' }, '1');
+ useConsentStore.getState().rejectAll();
+
+ // no active subscription, so the stale value survives until something else purges it
+ expect(localStorage.getItem('ga_client_id')).toBe('1');
+ });
+});
diff --git a/src/lib/consent/storageClasses.ts b/src/lib/consent/storageClasses.ts
new file mode 100644
index 00000000..67a8f85e
--- /dev/null
+++ b/src/lib/consent/storageClasses.ts
@@ -0,0 +1,183 @@
+'use client';
+
+/**
+ * @module consent/storageClasses
+ *
+ * Storage Classes for GDPR compliance.
+ *
+ * Every piece of client-side storage (localStorage, sessionStorage, cookies) that
+ * isn't strictly necessary for the app to function must be tied to a consent
+ * category (see `CookieCategory` in `./types`). This module lets call sites
+ * declare which category a given storage key belongs to, gates reads/writes
+ * against the user's current consent, and purges storage that becomes
+ * disallowed when consent is withdrawn (GDPR Art. 7(3): withdrawing consent
+ * must be as effective as giving it).
+ */
+import { useEffect } from 'react';
+import { useConsentStore } from './store';
+import type { CookieCategory } from './types';
+
+export type StorageArea = 'localStorage' | 'sessionStorage' | 'cookie';
+
+export interface StorageClassDescriptor {
+ /** Storage key (or cookie name) this descriptor governs. */
+ key: string;
+ /** GDPR consent category this storage entry belongs to. */
+ category: CookieCategory;
+ /** Underlying browser storage mechanism. */
+ area: StorageArea;
+}
+
+const registry = new Map();
+
+/** Declares a storage key's GDPR category so it can be gated and purged. */
+export function registerStorageKey(descriptor: StorageClassDescriptor): void {
+ registry.set(descriptor.key, descriptor);
+}
+
+export function unregisterStorageKey(key: string): void {
+ registry.delete(key);
+}
+
+export function getStorageClass(key: string): StorageClassDescriptor | undefined {
+ return registry.get(key);
+}
+
+export function listRegisteredStorageKeys(): StorageClassDescriptor[] {
+ return Array.from(registry.values());
+}
+
+/** Forgets every registered descriptor. Mainly useful for tests and logout flows. */
+export function clearStorageClassRegistry(): void {
+ registry.clear();
+}
+
+/** Whether the given category is currently consented to (necessary is always allowed). */
+export function isStorageClassAllowed(category: CookieCategory): boolean {
+ if (category === 'necessary') return true;
+ return useConsentStore.getState().preferences[category];
+}
+
+function readCookie(name: string): string | null {
+ if (typeof document === 'undefined' || !document.cookie) return null;
+ for (const entry of document.cookie.split('; ')) {
+ const separatorIndex = entry.indexOf('=');
+ if (separatorIndex === -1) continue;
+ if (entry.slice(0, separatorIndex) === name) {
+ return decodeURIComponent(entry.slice(separatorIndex + 1));
+ }
+ }
+ return null;
+}
+
+function writeCookie(name: string, value: string): void {
+ if (typeof document === 'undefined') return;
+ const maxAge = 365 * 24 * 60 * 60;
+ document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`;
+}
+
+function deleteCookie(name: string): void {
+ if (typeof document === 'undefined') return;
+ document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
+}
+
+function getBrowserStorage(area: 'localStorage' | 'sessionStorage'): Storage | null {
+ if (typeof window === 'undefined') return null;
+ try {
+ return area === 'localStorage' ? window.localStorage : window.sessionStorage;
+ } catch {
+ // Storage unavailable (e.g. private browsing with strict settings)
+ return null;
+ }
+}
+
+function removeFromStorage(descriptor: StorageClassDescriptor): void {
+ if (descriptor.area === 'cookie') {
+ deleteCookie(descriptor.key);
+ return;
+ }
+ try {
+ getBrowserStorage(descriptor.area)?.removeItem(descriptor.key);
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * Registers the descriptor and writes `value` to its storage area, but only
+ * if the descriptor's category is currently consented to. Returns whether
+ * the write actually happened.
+ */
+export function setClassifiedItem(descriptor: StorageClassDescriptor, value: string): boolean {
+ registerStorageKey(descriptor);
+ if (!isStorageClassAllowed(descriptor.category)) return false;
+
+ if (descriptor.area === 'cookie') {
+ writeCookie(descriptor.key, value);
+ return true;
+ }
+ const storage = getBrowserStorage(descriptor.area);
+ if (!storage) return false;
+ try {
+ storage.setItem(descriptor.key, value);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Reads a previously registered key. Returns null for unregistered keys and
+ * for keys whose category is not currently consented to, even if a stale
+ * value is still physically present in storage.
+ */
+export function getClassifiedItem(key: string): string | null {
+ const descriptor = registry.get(key);
+ if (!descriptor || !isStorageClassAllowed(descriptor.category)) return null;
+
+ if (descriptor.area === 'cookie') return readCookie(key);
+ try {
+ return getBrowserStorage(descriptor.area)?.getItem(key) ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/** Removes a registered key's value from its underlying storage area. */
+export function removeClassifiedItem(key: string): void {
+ const descriptor = registry.get(key);
+ if (!descriptor) return;
+ removeFromStorage(descriptor);
+}
+
+/**
+ * Removes every registered storage entry whose category is no longer
+ * consented to. Returns the keys that were purged.
+ */
+export function purgeDisallowedStorage(): string[] {
+ const purged: string[] = [];
+ for (const descriptor of registry.values()) {
+ if (!isStorageClassAllowed(descriptor.category)) {
+ removeFromStorage(descriptor);
+ purged.push(descriptor.key);
+ }
+ }
+ return purged;
+}
+
+/**
+ * Starts enforcing storage classes: purges any already-disallowed entries
+ * immediately, then re-purges on every consent change. Returns an
+ * unsubscribe function.
+ */
+export function enforceStorageClasses(): () => void {
+ purgeDisallowedStorage();
+ return useConsentStore.subscribe(() => {
+ purgeDisallowedStorage();
+ });
+}
+
+/** React hook that runs {@link enforceStorageClasses} for the lifetime of the component. */
+export function useStorageClassEnforcement(): void {
+ useEffect(() => enforceStorageClasses(), []);
+}