diff --git a/CHANGELOG.md b/CHANGELOG.md index ef4cfae..1bf3457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### 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` +- **`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 83de3c4..eed5b11 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. @@ -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/__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-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/__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-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/__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-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/__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) => { 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/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/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; } | { 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 =