From 2845fd5dc7f6aeb74ea6f15696bf81b3ff0b592f Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 24 Feb 2026 14:48:22 +0100 Subject: [PATCH 1/3] Narrow classify() and deriveHints() to accept StoredSignals Neither function uses userAgent or viewport, which are stripped before storage. Accepting RawSignals made the API claim those fields were relevant when they never were. StoredSignals is the honest contract. --- CHANGELOG.md | 1 + docs/api/types.md | 6 +++--- packages/types/src/classify.ts | 4 ++-- packages/types/src/hints.ts | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef4cfae..b052b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Breaking Changes - **Rename `ConnectionTier` value `'fast'` → `'high'`** — Aligns connection tier vocabulary with CPU, memory, and GPU tiers which all use `'low' | 'mid' | 'high'`. Update any code comparing `tiers.connection === 'fast'` to use `'high'` instead +- **`classify()` and `deriveHints()` now accept `StoredSignals` instead of `RawSignals`** — These functions never used `userAgent` or `viewport` (which are stripped before storage). The narrower type makes the API honest. Existing call sites are unaffected — `RawSignals` is structurally assignable to `StoredSignals` - **middleware-fastify: normalized return shape** — `createDeviceRouter()` now returns raw Fastify hooks instead of a `fastify-plugin` wrapped plugin. Migrate `await app.register(middleware)` → `app.addHook('preHandler', middleware)`. When using `injectProbe: true`, register the injection hook separately: `app.addHook('onSend', injectionMiddleware)`. Removed `fastify-plugin` dependency ### Features diff --git a/docs/api/types.md b/docs/api/types.md index 83de3c4..e3fa216 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -2,9 +2,9 @@ ## Functions -### `classify(signals: RawSignals): DeviceTiers` +### `classify(signals: StoredSignals): DeviceTiers` -Classifies raw device signals into capability tiers. +Classifies device signals into capability tiers. ```typescript import { classify } from '@device-router/types'; @@ -17,7 +17,7 @@ const tiers = classify({ // { cpu: 'high', memory: 'high', connection: 'high', gpu: 'none' } ``` -### `deriveHints(tiers: DeviceTiers, signals?: RawSignals): RenderingHints` +### `deriveHints(tiers: DeviceTiers, signals?: StoredSignals): RenderingHints` Derives rendering hints from device tiers. diff --git a/packages/types/src/classify.ts b/packages/types/src/classify.ts index 323d8fa..9abca98 100644 --- a/packages/types/src/classify.ts +++ b/packages/types/src/classify.ts @@ -1,5 +1,5 @@ import type { - RawSignals, + StoredSignals, CpuTier, MemoryTier, ConnectionTier, @@ -68,7 +68,7 @@ export function classifyGpu( return 'mid'; } -export function classify(signals: RawSignals, thresholds?: TierThresholds): DeviceTiers { +export function classify(signals: StoredSignals, thresholds?: TierThresholds): DeviceTiers { return { cpu: classifyCpu(signals.hardwareConcurrency, thresholds?.cpu), memory: classifyMemory(signals.deviceMemory, thresholds?.memory), diff --git a/packages/types/src/hints.ts b/packages/types/src/hints.ts index 4a3cb2c..41dcd69 100644 --- a/packages/types/src/hints.ts +++ b/packages/types/src/hints.ts @@ -1,6 +1,6 @@ -import type { DeviceTiers, RenderingHints, RawSignals } from './profile.js'; +import type { DeviceTiers, RenderingHints, StoredSignals } from './profile.js'; -export function deriveHints(tiers: DeviceTiers, signals?: RawSignals): RenderingHints { +export function deriveHints(tiers: DeviceTiers, signals?: StoredSignals): RenderingHints { const isLowEnd = tiers.cpu === 'low' || tiers.memory === 'low'; const isSlowConnection = tiers.connection === '2g' || tiers.connection === '3g'; const isBatteryConstrained = From 4d7684700f5874b2245257482ce785cf67dcae70 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 24 Feb 2026 15:03:14 +0100 Subject: [PATCH 2/3] Narrow profile:store event to StoredSignals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event emitted the raw probe payload (with userAgent/viewport) but is named 'profile:store' — it should carry what was actually stored. Endpoints now emit storedSignals, and the event type matches. --- CHANGELOG.md | 1 + docs/api/types.md | 2 +- packages/middleware-express/src/endpoint.ts | 7 ++++++- packages/middleware-fastify/src/endpoint.ts | 7 ++++++- packages/middleware-hono/src/endpoint.ts | 7 ++++++- packages/middleware-koa/src/endpoint.ts | 7 ++++++- packages/types/src/events.ts | 10 ++++++++-- 7 files changed, 34 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b052b1e..1bf3457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **Rename `ConnectionTier` value `'fast'` → `'high'`** — Aligns connection tier vocabulary with CPU, memory, and GPU tiers which all use `'low' | 'mid' | 'high'`. Update any code comparing `tiers.connection === 'fast'` to use `'high'` instead - **`classify()` and `deriveHints()` now accept `StoredSignals` instead of `RawSignals`** — These functions never used `userAgent` or `viewport` (which are stripped before storage). The narrower type makes the API honest. Existing call sites are unaffected — `RawSignals` is structurally assignable to `StoredSignals` +- **`profile:store` event now carries `StoredSignals` instead of `RawSignals`** — The event previously emitted the raw probe payload (including `userAgent`/`viewport`), not what was actually stored. The `signals` field now matches the persisted data. `bot:reject` still carries `RawSignals` (it fires before stripping) - **middleware-fastify: normalized return shape** — `createDeviceRouter()` now returns raw Fastify hooks instead of a `fastify-plugin` wrapped plugin. Migrate `await app.register(middleware)` → `app.addHook('preHandler', middleware)`. When using `injectProbe: true`, register the injection hook separately: `app.addHook('onSend', injectionMiddleware)`. Removed `fastify-plugin` dependency ### Features diff --git a/docs/api/types.md b/docs/api/types.md index e3fa216..eed5b11 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -224,7 +224,7 @@ type DeviceRouterEvent = source: ProfileSource; durationMs: number; } - | { type: 'profile:store'; sessionToken: string; signals: RawSignals; durationMs: number } + | { type: 'profile:store'; sessionToken: string; signals: StoredSignals; durationMs: number } | { type: 'bot:reject'; sessionToken: string; signals: RawSignals } | { type: 'error'; error: unknown; phase: 'middleware' | 'endpoint'; sessionToken?: string }; ``` diff --git a/packages/middleware-express/src/endpoint.ts b/packages/middleware-express/src/endpoint.ts index cc8e4e7..68044bf 100644 --- a/packages/middleware-express/src/endpoint.ts +++ b/packages/middleware-express/src/endpoint.ts @@ -63,7 +63,12 @@ export function createProbeEndpoint(options: EndpointOptions) { await storage.set(sessionToken, profile, ttl); const durationMs = performance.now() - start; - emitEvent(onEvent, { type: 'profile:store', sessionToken, signals, durationMs }); + emitEvent(onEvent, { + type: 'profile:store', + sessionToken, + signals: storedSignals, + durationMs, + }); res.cookie(cookieName, sessionToken, { path: cookiePath, diff --git a/packages/middleware-fastify/src/endpoint.ts b/packages/middleware-fastify/src/endpoint.ts index 132806d..aa719a4 100644 --- a/packages/middleware-fastify/src/endpoint.ts +++ b/packages/middleware-fastify/src/endpoint.ts @@ -64,7 +64,12 @@ export function createProbeEndpoint(options: EndpointOptions) { await storage.set(sessionToken, profile, ttl); const durationMs = performance.now() - start; - emitEvent(onEvent, { type: 'profile:store', sessionToken, signals, durationMs }); + emitEvent(onEvent, { + type: 'profile:store', + sessionToken, + signals: storedSignals, + durationMs, + }); reply.setCookie(cookieName, sessionToken, { path: cookiePath, diff --git a/packages/middleware-hono/src/endpoint.ts b/packages/middleware-hono/src/endpoint.ts index 983ecb1..06b18ef 100644 --- a/packages/middleware-hono/src/endpoint.ts +++ b/packages/middleware-hono/src/endpoint.ts @@ -66,7 +66,12 @@ export function createProbeEndpoint(options: EndpointOptions): Handler { await storage.set(sessionToken, profile, ttl); const durationMs = performance.now() - start; - emitEvent(onEvent, { type: 'profile:store', sessionToken, signals, durationMs }); + emitEvent(onEvent, { + type: 'profile:store', + sessionToken, + signals: storedSignals, + durationMs, + }); setCookie(c, cookieName, sessionToken, { path: cookiePath, diff --git a/packages/middleware-koa/src/endpoint.ts b/packages/middleware-koa/src/endpoint.ts index b5ada34..2191624 100644 --- a/packages/middleware-koa/src/endpoint.ts +++ b/packages/middleware-koa/src/endpoint.ts @@ -65,7 +65,12 @@ export function createProbeEndpoint(options: EndpointOptions) { await storage.set(sessionToken, profile, ttl); const durationMs = performance.now() - start; - emitEvent(onEvent, { type: 'profile:store', sessionToken, signals, durationMs }); + emitEvent(onEvent, { + type: 'profile:store', + sessionToken, + signals: storedSignals, + durationMs, + }); ctx.cookies.set(cookieName, sessionToken, { path: cookiePath, diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 153feb5..0e16812 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -1,4 +1,10 @@ -import type { DeviceTiers, RenderingHints, RawSignals, ProfileSource } from './profile.js'; +import type { + DeviceTiers, + RenderingHints, + StoredSignals, + RawSignals, + ProfileSource, +} from './profile.js'; export type DeviceRouterEvent = | { @@ -12,7 +18,7 @@ export type DeviceRouterEvent = | { type: 'profile:store'; sessionToken: string; - signals: RawSignals; + signals: StoredSignals; durationMs: number; } | { From a87b2537dd10acccc04e40ba8c212cec10bccc36 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 24 Feb 2026 15:10:52 +0100 Subject: [PATCH 3/3] Test that profile:store event strips userAgent and viewport Verifies all four middleware endpoints emit StoredSignals (without userAgent/viewport) in the profile:store event, not the raw probe payload. --- .../src/__tests__/endpoint.test.ts | 25 +++++++++++++++++++ .../src/__tests__/endpoint.test.ts | 21 ++++++++++++++++ .../src/__tests__/endpoint.test.ts | 25 +++++++++++++++++++ .../src/__tests__/endpoint.test.ts | 24 ++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/packages/middleware-express/src/__tests__/endpoint.test.ts b/packages/middleware-express/src/__tests__/endpoint.test.ts index 691c259..ec62822 100644 --- a/packages/middleware-express/src/__tests__/endpoint.test.ts +++ b/packages/middleware-express/src/__tests__/endpoint.test.ts @@ -256,6 +256,31 @@ describe('createProbeEndpoint', () => { expect(typeof event.durationMs).toBe('number'); }); + it('strips userAgent and viewport from profile:store signals', async () => { + const events: DeviceRouterEvent[] = []; + const onEvent = vi.fn((e: DeviceRouterEvent) => { + events.push(e); + }); + + const handler = createProbeEndpoint({ storage, onEvent }); + const req = createMockReq({ + hardwareConcurrency: 4, + userAgent: 'Mozilla/5.0 Test', + viewport: { width: 1920, height: 1080 }, + }); + const res = createMockRes(); + + await handler(req, res); + + const event = events.find((e) => e.type === 'profile:store') as Extract< + DeviceRouterEvent, + { type: 'profile:store' } + >; + expect(event.signals).toEqual({ hardwareConcurrency: 4 }); + expect(event.signals).not.toHaveProperty('userAgent'); + expect(event.signals).not.toHaveProperty('viewport'); + }); + it('emits bot:reject when bot detected', async () => { const events: DeviceRouterEvent[] = []; const onEvent = vi.fn((e: DeviceRouterEvent) => { diff --git a/packages/middleware-fastify/src/__tests__/endpoint.test.ts b/packages/middleware-fastify/src/__tests__/endpoint.test.ts index aef1104..cc10fc3 100644 --- a/packages/middleware-fastify/src/__tests__/endpoint.test.ts +++ b/packages/middleware-fastify/src/__tests__/endpoint.test.ts @@ -215,6 +215,27 @@ describe('createProbeEndpoint', () => { ); }); + it('strips userAgent and viewport from profile:store signals', async () => { + const onEvent = vi.fn(); + const handler = createProbeEndpoint({ storage, onEvent }); + const req = createMockReq({ + hardwareConcurrency: 4, + userAgent: 'Mozilla/5.0 Test', + viewport: { width: 1920, height: 1080 }, + }); + const reply = createMockReply(); + + await handler(req, reply); + + const event = onEvent.mock.calls[0][0] as Extract< + DeviceRouterEvent, + { type: 'profile:store' } + >; + expect(event.signals).toEqual({ hardwareConcurrency: 4 }); + expect(event.signals).not.toHaveProperty('userAgent'); + expect(event.signals).not.toHaveProperty('viewport'); + }); + it('emits bot:reject when bot detected', async () => { const onEvent = vi.fn(); const handler = createProbeEndpoint({ storage, onEvent }); diff --git a/packages/middleware-hono/src/__tests__/endpoint.test.ts b/packages/middleware-hono/src/__tests__/endpoint.test.ts index 4133a20..95fa10f 100644 --- a/packages/middleware-hono/src/__tests__/endpoint.test.ts +++ b/packages/middleware-hono/src/__tests__/endpoint.test.ts @@ -196,6 +196,31 @@ describe('createProbeEndpoint (hono)', () => { expect(typeof (event as { durationMs: number }).durationMs).toBe('number'); }); + it('strips userAgent and viewport from profile:store signals', async () => { + const onEvent = vi.fn(); + const app = new Hono(); + app.post('/probe', createProbeEndpoint({ storage, onEvent })); + + const res = await app.request('/probe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + hardwareConcurrency: 4, + userAgent: 'Mozilla/5.0 Test', + viewport: { width: 1920, height: 1080 }, + }), + }); + + expect(res.status).toBe(200); + const event = onEvent.mock.calls[0][0] as Extract< + DeviceRouterEvent, + { type: 'profile:store' } + >; + expect(event.signals).toEqual({ hardwareConcurrency: 4 }); + expect(event.signals).not.toHaveProperty('userAgent'); + expect(event.signals).not.toHaveProperty('viewport'); + }); + it('emits bot:reject when bot detected', async () => { const onEvent = vi.fn(); const app = new Hono(); diff --git a/packages/middleware-koa/src/__tests__/endpoint.test.ts b/packages/middleware-koa/src/__tests__/endpoint.test.ts index a4f3f4f..17beb22 100644 --- a/packages/middleware-koa/src/__tests__/endpoint.test.ts +++ b/packages/middleware-koa/src/__tests__/endpoint.test.ts @@ -189,6 +189,30 @@ describe('createProbeEndpoint (koa)', () => { expect(typeof event.durationMs).toBe('number'); }); + it('strips userAgent and viewport from profile:store signals', async () => { + const events: DeviceRouterEvent[] = []; + const onEvent = vi.fn((e: DeviceRouterEvent) => { + events.push(e); + }); + + const handler = createProbeEndpoint({ storage, onEvent }); + const ctx = createMockCtx({ + hardwareConcurrency: 4, + userAgent: 'Mozilla/5.0 Test', + viewport: { width: 1920, height: 1080 }, + }); + + await handler(ctx); + + const event = events.find((e) => e.type === 'profile:store') as Extract< + DeviceRouterEvent, + { type: 'profile:store' } + >; + expect(event.signals).toEqual({ hardwareConcurrency: 4 }); + expect(event.signals).not.toHaveProperty('userAgent'); + expect(event.signals).not.toHaveProperty('viewport'); + }); + it('emits bot:reject when bot detected', async () => { const events: DeviceRouterEvent[] = []; const onEvent = vi.fn((e: DeviceRouterEvent) => {