From f8766947d1a03b24afb683b84e9743397b333b17 Mon Sep 17 00:00:00 2001 From: Lisa Date: Wed, 4 Mar 2026 20:25:28 +0100 Subject: [PATCH] chore: bump to 1.0.1, add privacy documentation --- CHANGELOG.md | 6 + PRIVACY.md | 62 +++++ README.md | 1 + docs/getting-started.md | 40 +--- docs/privacy.md | 288 +++++++++++++++++++++++ package.json | 2 +- packages/middleware-express/package.json | 2 +- packages/middleware-fastify/package.json | 2 +- packages/middleware-hono/package.json | 2 +- packages/middleware-koa/package.json | 2 +- packages/probe/package.json | 2 +- packages/storage/package.json | 2 +- packages/types/package.json | 2 +- 13 files changed, 367 insertions(+), 46 deletions(-) create mode 100644 PRIVACY.md create mode 100644 docs/privacy.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4b1c6..a58f8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.1 (2026-03-04) + +### Documentation + +- **Privacy documentation** — Added `PRIVACY.md` and dedicated privacy guide (`docs/privacy.md`) with signal inventory, cookie details, fingerprinting risk assessment, consent integration examples, data subject rights, and regulatory guidance (GDPR, ePrivacy, CCPA, LGPD) + ## 1.0.0 (2026-03-04) ### Breaking Changes diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..52811ac --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,62 @@ +# Privacy + +DeviceRouter collects device capability signals to classify devices and derive rendering hints. +This document summarizes what is collected, what is not, and where to find detailed guidance. + +## What is collected + +The client probe reads the following signals via browser APIs: + +- CPU core count (`hardwareConcurrency`) +- Device memory (`deviceMemory`) +- Connection type, downlink speed, RTT, data saver mode (`navigator.connection`) +- Pixel ratio (`devicePixelRatio`) +- Prefers reduced motion / color scheme (`matchMedia`) +- GPU renderer (WebGL debug info) +- Battery level and charging status (`navigator.getBattery()`) + +All signals are optional — the probe gracefully degrades based on what the browser supports. + +## What is NOT stored + +- **User agent** — collected for bot/crawler filtering, then stripped before storage +- **Viewport dimensions** — collected for bot/crawler filtering, then stripped before storage + +These two fields never reach the storage layer. + +## Cookie + +DeviceRouter sets a single session cookie: + +| Attribute | Value | +| ---------- | ----------------------- | +| Name | `device-router-session` | +| `httpOnly` | `true` | +| `sameSite` | `lax` | +| `secure` | `false` (configurable) | +| `path` | `/` (configurable) | +| Max age | 24 hours (configurable) | + +The cookie value is a random UUID v4 (`crypto.randomUUID()`). It contains no user data — it is +solely a key to look up the stored device profile. + +## No tracking, no profiling + +- Profiles are tied to random session tokens, not to users or accounts +- No data is shared with third parties +- No cross-session or cross-site tracking +- No personally identifiable information (PII) is collected or stored + +## Regulatory implications + +Depending on your jurisdiction, collecting device signals and setting a cookie may require user +consent. DeviceRouter provides the technical building blocks but does **not** include a consent +mechanism — that is your responsibility. + +For detailed regulatory guidance (GDPR, ePrivacy, CCPA, LGPD) and consent integration examples, +see the [Privacy Guide](docs/privacy.md). + +--- + +> **Disclaimer:** This document is informational guidance, not legal advice. Consult a qualified +> privacy professional for your specific deployment. diff --git a/README.md b/README.md index ae8ac83..24e09ac 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ Open http://localhost:3000 — the probe runs on first load, refresh to see your ## Documentation - [Getting Started](docs/getting-started.md) +- [Privacy Guide](docs/privacy.md) — Signal inventory, cookie details, consent integration, GDPR/CCPA/LGPD guidance - [Observability](docs/observability.md) — Logging, metrics, and monitoring hooks - [Deployment Guide](docs/deployment.md) — Docker, Cloudflare Workers, serverless - [Meta-Framework Integration](docs/meta-frameworks.md) — Next.js, Remix, SvelteKit diff --git a/docs/getting-started.md b/docs/getting-started.md index cde76ab..3592820 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -306,45 +306,9 @@ const storage = new RedisStorageAdapter({ ## Privacy and Cookie Consent -DeviceRouter collects device capability signals (CPU cores, memory, GPU renderer, viewport, connection type, battery status, user agent) and links them to a session cookie. Depending on your jurisdiction, this has regulatory implications. +DeviceRouter collects device capability signals and links them to a session cookie. Depending on your jurisdiction (EU, UK, California, Brazil, and others), this has regulatory implications — you may need to obtain user consent before loading the probe script. -### Collected signals and fingerprinting - -The signals DeviceRouter collects overlap with known browser fingerprinting vectors. Regulators evaluate the _capability_ of the data to identify users, not just the stated intent. Even though DeviceRouter uses these signals solely for adaptive rendering, the combination of GPU renderer, hardware concurrency, device memory, viewport, and user agent can narrow down device identity — and regulators treat that as personal data. - -### EU: GDPR and ePrivacy Directive - -Two regulations apply independently: - -- **ePrivacy Directive (Article 5(3))** covers both setting the `device-router-session` cookie _and_ reading device signals from browser APIs. Both count as accessing information stored on terminal equipment. The "strictly necessary" exemption is interpreted narrowly by the EDPB, CNIL, and ICO — it requires that the service _cannot function_ without the data, not that it functions _better_ with it. Adaptive rendering has not been recognized as strictly necessary by any regulator. - -- **GDPR** applies because the collected signals in aggregate constitute personal data (Recital 30 explicitly references device identifiers and the profiles they can create). You need a lawful basis under Article 6 — consent (Article 6(1)(a)) is the most defensible option. - -**In practice:** implement a cookie consent mechanism before deploying DeviceRouter in the EU. - -### UK - -The UK GDPR and PECR follow the same framework as the EU. The ICO has been particularly vocal about fingerprinting-like techniques — treat the requirements as equivalent. - -### California (CCPA/CPRA) - -The collected signals qualify as personal information under CCPA. You must: - -- Disclose the collection in your privacy notice (notice at collection) -- Honor access and deletion requests for stored profiles -- If you never sell or share the data with third parties, the "Do Not Sell" opt-out does not apply, but the data is still subject to consumer rights - -### Brazil (LGPD) - -The ANPD treats cookie and fingerprinting data as personal data. Consent must be "free, informed and unequivocal." There is no "strictly necessary" carve-out equivalent to the ePrivacy Directive. - -### Recommendations - -- Obtain consent before loading the probe script in jurisdictions that require it -- Include DeviceRouter's signal collection in your privacy policy -- Set a reasonable TTL — shorter sessions reduce the regulatory surface -- Use `MemoryStorageAdapter` or configure Redis key expiration so profiles are not retained beyond their useful life -- Consider omitting high-entropy signals you do not need (e.g., if you only need CPU/memory tiers, you may not need `gpuRenderer` or `battery`) +For a full signal inventory, fingerprinting risk assessment, cookie details, consent integration examples, and jurisdiction-specific guidance, see the [Privacy Guide](privacy.md). > **Note:** This section is informational guidance, not legal advice. Consult a qualified privacy professional for your specific deployment. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..2263d82 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,288 @@ +# Privacy Guide + +DeviceRouter collects browser signals, classifies devices, and stores profiles linked to a session +cookie. This guide covers exactly what data flows through the system, how it is stored, the +fingerprinting implications, and how to integrate consent mechanisms. + +## Signal Inventory + +The probe collects these signals via browser APIs. Signals marked "Stored" are persisted in the +storage adapter; the rest are used during probe submission and discarded. + +| Signal | Browser API | Stored | Fingerprinting risk | +| ---------------------- | --------------------------------- | ------ | ------------------- | +| CPU core count | `hardwareConcurrency` | Yes | Low | +| Device memory | `deviceMemory` | Yes | Low | +| Connection type | `navigator.connection` | Yes | Low | +| Downlink speed | `navigator.connection` | Yes | Low | +| Round-trip time (RTT) | `navigator.connection` | Yes | Low | +| Data saver mode | `navigator.connection` | Yes | Low | +| Pixel ratio | `devicePixelRatio` | Yes | Medium | +| Prefers reduced motion | `matchMedia` | Yes | Low | +| Prefers color scheme | `matchMedia` | Yes | Low | +| GPU renderer | WebGL `WEBGL_debug_renderer_info` | Yes | High | +| Battery level | `navigator.getBattery()` | Yes | Medium | +| Battery charging | `navigator.getBattery()` | Yes | Low | +| User agent | `navigator.userAgent` | **No** | High | +| Viewport dimensions | `window.innerWidth/Height` | **No** | Medium | + +All signals are optional — the probe gracefully degrades when a browser API is unavailable. + +### Fingerprinting risk ratings + +- **High** — GPU renderer strings and user agents are well-known fingerprinting vectors. The + combination of these with other signals can narrow down device identity significantly. +- **Medium** — Pixel ratio and battery level add entropy when combined with other signals but are + low-risk in isolation. +- **Low** — Values like CPU core count or connection type have limited cardinality and contribute + minimal fingerprinting entropy on their own. + +## What Gets Stripped + +`userAgent` and `viewport` are collected by the probe but **stripped before storage**. The endpoint +destructures them out before building the stored profile: + +```typescript +const { userAgent: _userAgent, viewport: _viewport, ...storedSignals } = signals; +``` + +This is enforced at the type level: + +```typescript +type StoredSignals = Omit; +``` + +**Why collect them at all?** Both are used for bot/crawler detection (`isBotSignals()`) at the probe +endpoint. Once bot filtering is complete, they are discarded. + +## Cookie Details + +DeviceRouter sets a single session cookie per visitor. + +| Attribute | Default value | Configurable | +| ---------- | ------------------------- | --------------------- | +| Name | `device-router-session` | `cookieName` option | +| `httpOnly` | `true` | No (hardcoded) | +| `sameSite` | `lax` | No (hardcoded) | +| `secure` | `false` | `cookieSecure` option | +| `path` | `/` | `cookiePath` option | +| Max age | 86 400 seconds (24 hours) | `ttl` option | + +**Security notes:** + +- `httpOnly: true` prevents client-side JavaScript from reading the cookie value +- `sameSite: lax` prevents the cookie from being sent on cross-origin requests +- Set `cookieSecure: true` in production to restrict the cookie to HTTPS + +## Session ID + +Session tokens are UUID v4 values generated via `crypto.randomUUID()` (Node.js) or +`globalThis.crypto.randomUUID()` (Hono/edge runtimes). They are: + +- Cryptographically random (122 bits of entropy) +- Not derived from any user data +- Not sequential or predictable +- Used solely as a storage key to retrieve the device profile + +If a valid session cookie already exists, the existing token is reused — no new UUID is generated. + +## Storage and Data Retention + +### MemoryStorageAdapter + +- Profiles are stored in a `Map` with a `setTimeout` for automatic expiration +- When the TTL expires, the entry is deleted from memory +- The timer is `unref()`'d so it does not prevent Node.js from exiting +- A passive expiry check on `get()` ensures stale entries are caught even if the timer fires late + +### RedisStorageAdapter + +- Profiles are stored as JSON strings with the Redis `EX` flag for native key expiration +- Keys use a configurable prefix (default: `dr:profile:`) prepended to the session token +- When `scan()` is available on the Redis client, non-blocking `SCAN` is used instead of `KEYS` + +### TTL configuration + +The default TTL is 86 400 seconds (24 hours), configurable via the `ttl` option on +`createDeviceRouter()` or `createProbeEndpoint()`. Shorter TTLs reduce the window during which +stored profiles exist and limit regulatory exposure. + +### Data management methods + +Both storage adapters implement these methods for managing stored data: + +| Method | Description | +| ---------- | -------------------------------------------- | +| `get()` | Retrieve a profile by session token | +| `delete()` | Delete a specific profile by session token | +| `clear()` | Delete all stored profiles | +| `keys()` | List all session tokens (without key prefix) | +| `count()` | Return the number of stored profiles | +| `exists()` | Check whether a profile exists for a token | + +## Fingerprinting Risk Assessment + +While DeviceRouter uses signals for adaptive rendering — not identification — regulators evaluate +the **capability** of the data to identify users, not just the stated intent. + +### High-risk combinations + +The following signal combinations, when present together, can significantly narrow down device +identity: + +- **GPU renderer + hardware concurrency + device memory** — Together these can distinguish specific + device models with moderate confidence +- **GPU renderer + pixel ratio + battery level** — On mobile devices, this combination provides + relatively high entropy + +### Risk mitigation + +- **Strip what you do not need.** If your application only needs CPU and memory tiers, you do not + need to store `gpuRenderer` or `battery`. The probe collects all available signals, but you can + post-process or filter before classification. +- **Shorten TTL.** Shorter sessions reduce the time window for potential correlation. +- **Avoid combining with other identifiers.** Do not link device profiles with user accounts, + analytics IDs, or other tracking mechanisms. + +## Regulatory Guidance + +### EU: GDPR and ePrivacy Directive + +Two regulations apply independently: + +- **ePrivacy Directive (Article 5(3))** covers both setting the `device-router-session` cookie _and_ + reading device signals from browser APIs. Both count as accessing information stored on terminal + equipment. The "strictly necessary" exemption is interpreted narrowly by the EDPB, CNIL, and + ICO — it requires that the service _cannot function_ without the data, not that it functions + _better_ with it. Adaptive rendering has not been recognized as strictly necessary by any + regulator. + +- **GDPR** applies because the collected signals in aggregate constitute personal data (Recital 30 + explicitly references device identifiers and the profiles they can create). You need a lawful basis + under Article 6 — consent (Article 6(1)(a)) is the most defensible option. + +**In practice:** implement a cookie consent mechanism before deploying DeviceRouter in the EU. + +### UK (PECR) + +The UK GDPR and PECR follow the same framework as the EU. The ICO has been particularly vocal about +fingerprinting-like techniques — treat the requirements as equivalent. + +### California (CCPA/CPRA) + +The collected signals qualify as personal information under CCPA. You must: + +- Disclose the collection in your privacy notice (notice at collection) +- Honor access and deletion requests for stored profiles +- If you never sell or share the data with third parties, the "Do Not Sell" opt-out does not apply, + but the data is still subject to consumer rights + +### Brazil (LGPD) + +The ANPD treats cookie and fingerprinting data as personal data. Consent must be "free, informed and +unequivocal." There is no "strictly necessary" carve-out equivalent to the ePrivacy Directive. + +## Consent Integration + +Load the probe script only after the user has given consent. This ensures no signals are collected +and no cookies are set without permission. + +### Example: conditional probe loading + +```html + +``` + +### Example: conditional auto-injection + +If you use the `injectProbe` option, gate the middleware behind your consent check: + +```typescript +import { createDeviceRouter } from '@device-router/middleware-express'; + +const { middleware, probeEndpoint, injectionMiddleware } = createDeviceRouter({ + storage, + injectProbe: true, +}); + +// Only inject the probe for users who have consented +app.use((req, res, next) => { + if (req.cookies['cookie-consent'] === 'accepted') { + return injectionMiddleware(req, res, next); + } + next(); +}); +``` + +### Example: server-side consent check on probe endpoint + +```typescript +app.post('/device-router/probe', (req, res, next) => { + if (req.cookies['cookie-consent'] !== 'accepted') { + return res.status(403).json({ error: 'Consent required' }); + } + probeEndpoint(req, res, next); +}); +``` + +## Data Subject Rights + +### Access requests + +Retrieve a stored profile using the session token from the cookie: + +```typescript +const sessionToken = req.cookies['device-router-session']; +if (sessionToken) { + const profile = await storage.get(sessionToken); + // Return profile data to the user +} +``` + +### Deletion requests + +Delete a specific profile and clear the cookie: + +```typescript +const sessionToken = req.cookies['device-router-session']; +if (sessionToken) { + await storage.delete(sessionToken); + res.clearCookie('device-router-session'); +} +``` + +### Bulk operations + +For administrative purposes, you can list or clear all stored profiles: + +```typescript +const count = await storage.count(); // Number of stored profiles +const tokens = await storage.keys(); // All session tokens +await storage.clear(); // Delete all profiles +``` + +## Bot Filtering + +When `rejectBots: true` (the default), the probe endpoint checks incoming submissions for bot +signals before processing. Bot-detected requests receive an HTTP 403 response and **no profile is +stored**. + +Detection covers: + +- Known bot/crawler user agent patterns (Googlebot, ChatGPT, Puppeteer, etc.) +- Headless GPU renderers (SwiftShader, LLVMpipe, Software Rasterizer) +- Empty signal payloads (no viewport, CPU, memory, or user agent) + +Since bot submissions are rejected before storage, no data retention or privacy considerations apply +to bot traffic. + +--- + +> **Disclaimer:** This guide is informational guidance, not legal advice. Consult a qualified +> privacy professional for your specific deployment. diff --git a/package.json b/package.json index 3eb53e7..2c2193f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "device-router", - "version": "1.0.0", + "version": "1.0.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/middleware-express/package.json b/packages/middleware-express/package.json index c8f926c..8c23ce6 100644 --- a/packages/middleware-express/package.json +++ b/packages/middleware-express/package.json @@ -1,6 +1,6 @@ { "name": "@device-router/middleware-express", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "description": "Express middleware for DeviceRouter — device classification and rendering hints per request", "license": "MIT", diff --git a/packages/middleware-fastify/package.json b/packages/middleware-fastify/package.json index 34ca168..17882dd 100644 --- a/packages/middleware-fastify/package.json +++ b/packages/middleware-fastify/package.json @@ -1,6 +1,6 @@ { "name": "@device-router/middleware-fastify", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "description": "Fastify middleware for DeviceRouter — device classification and rendering hints per request", "license": "MIT", diff --git a/packages/middleware-hono/package.json b/packages/middleware-hono/package.json index 43e22e3..3dd3a8e 100644 --- a/packages/middleware-hono/package.json +++ b/packages/middleware-hono/package.json @@ -1,6 +1,6 @@ { "name": "@device-router/middleware-hono", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "description": "Hono middleware for DeviceRouter — device classification and rendering hints, edge-compatible", "license": "MIT", diff --git a/packages/middleware-koa/package.json b/packages/middleware-koa/package.json index af3c86f..4e6eb10 100644 --- a/packages/middleware-koa/package.json +++ b/packages/middleware-koa/package.json @@ -1,6 +1,6 @@ { "name": "@device-router/middleware-koa", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "description": "Koa middleware for DeviceRouter — device classification and rendering hints per request", "license": "MIT", diff --git a/packages/probe/package.json b/packages/probe/package.json index 200ac9a..22d4d84 100644 --- a/packages/probe/package.json +++ b/packages/probe/package.json @@ -1,6 +1,6 @@ { "name": "@device-router/probe", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "description": "Lightweight (~1 KB gzipped) client-side probe for collecting device capability signals", "license": "MIT", diff --git a/packages/storage/package.json b/packages/storage/package.json index 169d7e6..551484e 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "@device-router/storage", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "description": "Storage adapters (memory, Redis) for persisting device profiles in DeviceRouter", "license": "MIT", diff --git a/packages/types/package.json b/packages/types/package.json index 4b4756e..4bfaea7 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@device-router/types", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "description": "Core types, device classification, and rendering hint derivation for DeviceRouter", "license": "MIT",