diff --git a/AGENTS.md b/AGENTS.md index 8bb7be9..ad7b06d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,12 @@ src/shared/constants.ts SIGNALS_HEADER ("x-mgpl-signals"), SIGNAL_LIMITS, ID_PA src/shared/crypto.ts base64url, randomBytes/randomId, sha256Hex, constantTimeEqual, canonicalJson (Web Crypto only) src/shared/signals.ts encodeSignals, signalHeaders, parseSignals/decodeSignals (validate untrusted input) src/browser/ "/browser": storage.ts (web/memory/custom), device.ts (deviceInfo, fingerprint inputs), - collect.ts (createSignalCollector) + collect.ts (createSignalCollector: collect, headers, subscribe/getSnapshot), + adapters.ts (signalFetch, mergeSignalHeaders, axiosSignals, kySignals, apolloSignals, signalStore) +src/react.ts "/react": useDeviceSignals (React is an optional peer) src/server/ "/server": signals.ts (readSignals, bindSignals, checkSignals, decide), ip.ts (clientIp), - headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone) + headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone), + adapters.ts (requestSecurity, fetchSecurity, expressSecurity, fastifySecurity) scripts/test.ts every part; browser code runs on simulated environments scripts/check-dist.mjs the built main and /browser bundles import no Node.js modules ``` @@ -25,7 +28,10 @@ scripts/check-dist.mjs the built main and /browser bundles import no Node.js m 1. **The shared and browser parts use no Node.js APIs** (no `node:*`, no `Buffer`): only Web Crypto, `TextEncoder`, `btoa`/`atob`. `npm run typecheck` compiles them without `@types/node`, and CI runs `scripts/check-dist.mjs` on the bundles. -2. **No runtime dependencies.** Frameworks (React, TanStack Query, Fastify…) stay in the consumers. +2. **No runtime dependencies; works with every library.** Adapters match a library's hook shape + structurally and never import it (axios, ky, Apollo, Express, Fastify…). React is the one optional + peer, used only by `/react`. Prefer a generic contract (`fetch`, headers, `subscribe`/`getSnapshot`) + over a new per-library adapter, and test every adapter (a real Express and Fastify app in `npm test`). 3. **Signals stay coarse.** No canvas, audio, WebGL, font or IP probing, nothing sent to third parties. Anything new that is collected goes in the README table "What is collected" in the same PR. 4. **Never store raw ids or tokens.** The server keeps `bindSignals()` output (SHA-256 of ids). diff --git a/README.md b/README.md index e51d35e..1147067 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Device signals collected in any frontend and checked on the backend, plus the server helpers every endpoint needs. Zero dependencies. -![version](https://img.shields.io/badge/version-0.1.0-E9A23B) ![checks](https://img.shields.io/badge/checks-32-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) +![version](https://img.shields.io/badge/version-0.2.0-E9A23B) ![checks](https://img.shields.io/badge/checks-44-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) @@ -21,10 +21,41 @@ npm install @mgpl-diagnostics/security-kit | --- | --- | --- | | `@mgpl-diagnostics/security-kit` | everywhere (browser, Node.js, workers, React Native) | `DeviceSignals` types, `SIGNALS_HEADER`, `encodeSignals` / `decodeSignals` (validated), `randomId`, `sha256Hex`, `constantTimeEqual`, base64url, `canonicalJson` | | `@mgpl-diagnostics/security-kit/browser` | any frontend, any framework | `createSignalCollector`, `deviceInfo`, pluggable storage (`webStorage`, `memoryStorage`, or your own sync/async store) | -| `@mgpl-diagnostics/security-kit/server` | Node.js ≥ 20 | `readSignals`, `bindSignals`, `checkSignals`, `decide`; `clientIp` behind proxies; `securityHeaders`, `corsPolicy`; `redact`, `maskEmail`, `maskPhone` | +| `@mgpl-diagnostics/security-kit/server` | Node.js ≥ 20 | `readSignals`, `bindSignals`, `checkSignals`, `decide`; `clientIp` behind proxies; `securityHeaders`, `corsPolicy`; `redact`, `maskEmail`, `maskPhone`; adapters for Express, Fastify and Fetch-standard servers | +| `@mgpl-diagnostics/security-kit/react` | React ≥ 18 (optional peer) | `useDeviceSignals(collector)` | Both `/browser` and `/server` re-export the shared entry. +## Works with any library + +Nothing here imports a framework. The adapters match each library's hook shape, so they add no +dependency, and anything not listed uses `collector.headers()` or `subscribe` / `getSnapshot` directly. + +| You use | Plug in | +| --- | --- | +| `@mgpl-diagnostics/client` | `createMgplGraphQLClient({ headers: () => signals.headers() })` | +| plain `fetch`, graphql-request, urql, Apollo `HttpLink`, ofetch, wretch | `fetch: signalFetch(signals)` | +| TanStack Query, SWR, RTK Query, Vue Query | call your client (or `signalFetch(signals)`) inside the query function; nothing else | +| axios | `axios.interceptors.request.use(axiosSignals(signals))` | +| ky | `ky.create({ hooks: { beforeRequest: [kySignals(signals)] } })` | +| Apollo Client | `setContext(apolloSignals(signals))` | +| graphql-ws, socket.io, Angular `HttpClient`, anything with a headers object | `await mergeSignalHeaders(signals, headers)` | +| React | `const s = useDeviceSignals(signals)` from `/react` | +| Svelte | `const store = signalStore(signals)`, then `$store` | +| Vue | `const s = shallowRef(signals.getSnapshot()); signals.subscribe((v) => (s.value = v))` | +| Solid, Angular signals, RxJS, MobX | wrap `signals.subscribe` / `signals.getSnapshot` (or `signalStore`) | +| React Native, Electron, Capacitor | pass `storage` / `environment` (see below) | + +| Server | Plug in | +| --- | --- | +| Fastify | `await app.register(fastifySecurity({ cors: { origins }, trustedHops: 1 }))` → `request.security` | +| Express, Connect, node:http | `app.use(expressSecurity({ cors: { origins } }))` → `req.security` | +| Hono, Next.js route handlers, Bun, Deno, workers, GraphQL Yoga | `fetchSecurity(opts).handle(request, remoteAddress)` → `{ security, headers, preflight }` | +| anything else, or a GraphQL context | `requestSecurity({ headers, remoteAddress, trustedHops })` → `{ ip, signals, userAgent, origin }` | + +`security.signals` is already validated (or `null`). Leave `cors` unset when another plugin +(`@fastify/cors`, Yoga's own CORS) already handles it. + ## Device signals ```mermaid @@ -133,7 +164,7 @@ logger.info(redact({ body, headers })); // passwords, tokens, cookies, keys → ```bash npm run typecheck # also checks that the shared and browser parts compile without Node.js types -npm test # 32 checks +npm test # 44 checks, including real Express and Fastify apps npm run build && node scripts/check-dist.mjs npm run llms # regenerate llms-full.txt (CI fails when it is stale) ``` diff --git a/llms-full.txt b/llms-full.txt index 62e5877..ff8e4f9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @mgpl-diagnostics/security-kit — full documentation and source -> Generated by scripts/llms-full.mjs. Do not edit by hand. 21 files. +> Generated by scripts/llms-full.mjs. Do not edit by hand. 24 files. ## Contents @@ -11,11 +11,14 @@ - scripts/check-dist.mjs - scripts/llms-full.mjs - scripts/test.ts +- src/browser/adapters.ts - src/browser/collect.ts - src/browser/device.ts - src/browser/index.ts - src/browser/storage.ts - src/index.ts +- src/react.ts +- src/server/adapters.ts - src/server/headers.ts - src/server/index.ts - src/server/ip.ts @@ -39,7 +42,7 @@ Device signals collected in any frontend and checked on the backend, plus the server helpers every endpoint needs. Zero dependencies. -![version](https://img.shields.io/badge/version-0.1.0-E9A23B) ![checks](https://img.shields.io/badge/checks-32-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) +![version](https://img.shields.io/badge/version-0.2.0-E9A23B) ![checks](https://img.shields.io/badge/checks-44-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) @@ -54,10 +57,41 @@ npm install @mgpl-diagnostics/security-kit | --- | --- | --- | | `@mgpl-diagnostics/security-kit` | everywhere (browser, Node.js, workers, React Native) | `DeviceSignals` types, `SIGNALS_HEADER`, `encodeSignals` / `decodeSignals` (validated), `randomId`, `sha256Hex`, `constantTimeEqual`, base64url, `canonicalJson` | | `@mgpl-diagnostics/security-kit/browser` | any frontend, any framework | `createSignalCollector`, `deviceInfo`, pluggable storage (`webStorage`, `memoryStorage`, or your own sync/async store) | -| `@mgpl-diagnostics/security-kit/server` | Node.js ≥ 20 | `readSignals`, `bindSignals`, `checkSignals`, `decide`; `clientIp` behind proxies; `securityHeaders`, `corsPolicy`; `redact`, `maskEmail`, `maskPhone` | +| `@mgpl-diagnostics/security-kit/server` | Node.js ≥ 20 | `readSignals`, `bindSignals`, `checkSignals`, `decide`; `clientIp` behind proxies; `securityHeaders`, `corsPolicy`; `redact`, `maskEmail`, `maskPhone`; adapters for Express, Fastify and Fetch-standard servers | +| `@mgpl-diagnostics/security-kit/react` | React ≥ 18 (optional peer) | `useDeviceSignals(collector)` | Both `/browser` and `/server` re-export the shared entry. +## Works with any library + +Nothing here imports a framework. The adapters match each library's hook shape, so they add no +dependency, and anything not listed uses `collector.headers()` or `subscribe` / `getSnapshot` directly. + +| You use | Plug in | +| --- | --- | +| `@mgpl-diagnostics/client` | `createMgplGraphQLClient({ headers: () => signals.headers() })` | +| plain `fetch`, graphql-request, urql, Apollo `HttpLink`, ofetch, wretch | `fetch: signalFetch(signals)` | +| TanStack Query, SWR, RTK Query, Vue Query | call your client (or `signalFetch(signals)`) inside the query function; nothing else | +| axios | `axios.interceptors.request.use(axiosSignals(signals))` | +| ky | `ky.create({ hooks: { beforeRequest: [kySignals(signals)] } })` | +| Apollo Client | `setContext(apolloSignals(signals))` | +| graphql-ws, socket.io, Angular `HttpClient`, anything with a headers object | `await mergeSignalHeaders(signals, headers)` | +| React | `const s = useDeviceSignals(signals)` from `/react` | +| Svelte | `const store = signalStore(signals)`, then `$store` | +| Vue | `const s = shallowRef(signals.getSnapshot()); signals.subscribe((v) => (s.value = v))` | +| Solid, Angular signals, RxJS, MobX | wrap `signals.subscribe` / `signals.getSnapshot` (or `signalStore`) | +| React Native, Electron, Capacitor | pass `storage` / `environment` (see below) | + +| Server | Plug in | +| --- | --- | +| Fastify | `await app.register(fastifySecurity({ cors: { origins }, trustedHops: 1 }))` → `request.security` | +| Express, Connect, node:http | `app.use(expressSecurity({ cors: { origins } }))` → `req.security` | +| Hono, Next.js route handlers, Bun, Deno, workers, GraphQL Yoga | `fetchSecurity(opts).handle(request, remoteAddress)` → `{ security, headers, preflight }` | +| anything else, or a GraphQL context | `requestSecurity({ headers, remoteAddress, trustedHops })` → `{ ip, signals, userAgent, origin }` | + +`security.signals` is already validated (or `null`). Leave `cors` unset when another plugin +(`@fastify/cors`, Yoga's own CORS) already handles it. + ## Device signals ```mermaid @@ -166,7 +200,7 @@ logger.info(redact({ body, headers })); // passwords, tokens, cookies, keys → ```bash npm run typecheck # also checks that the shared and browser parts compile without Node.js types -npm test # 32 checks +npm test # 44 checks, including real Express and Fastify apps npm run build && node scripts/check-dist.mjs npm run llms # regenerate llms-full.txt (CI fails when it is stale) ``` @@ -195,9 +229,12 @@ src/shared/constants.ts SIGNALS_HEADER ("x-mgpl-signals"), SIGNAL_LIMITS, ID_PA src/shared/crypto.ts base64url, randomBytes/randomId, sha256Hex, constantTimeEqual, canonicalJson (Web Crypto only) src/shared/signals.ts encodeSignals, signalHeaders, parseSignals/decodeSignals (validate untrusted input) src/browser/ "/browser": storage.ts (web/memory/custom), device.ts (deviceInfo, fingerprint inputs), - collect.ts (createSignalCollector) + collect.ts (createSignalCollector: collect, headers, subscribe/getSnapshot), + adapters.ts (signalFetch, mergeSignalHeaders, axiosSignals, kySignals, apolloSignals, signalStore) +src/react.ts "/react": useDeviceSignals (React is an optional peer) src/server/ "/server": signals.ts (readSignals, bindSignals, checkSignals, decide), ip.ts (clientIp), - headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone) + headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone), + adapters.ts (requestSecurity, fetchSecurity, expressSecurity, fastifySecurity) scripts/test.ts every part; browser code runs on simulated environments scripts/check-dist.mjs the built main and /browser bundles import no Node.js modules ``` @@ -207,7 +244,10 @@ scripts/check-dist.mjs the built main and /browser bundles import no Node.js m 1. **The shared and browser parts use no Node.js APIs** (no `node:*`, no `Buffer`): only Web Crypto, `TextEncoder`, `btoa`/`atob`. `npm run typecheck` compiles them without `@types/node`, and CI runs `scripts/check-dist.mjs` on the bundles. -2. **No runtime dependencies.** Frameworks (React, TanStack Query, Fastify…) stay in the consumers. +2. **No runtime dependencies; works with every library.** Adapters match a library's hook shape + structurally and never import it (axios, ky, Apollo, Express, Fastify…). React is the one optional + peer, used only by `/react`. Prefer a generic contract (`fetch`, headers, `subscribe`/`getSnapshot`) + over a new per-library adapter, and test every adapter (a real Express and Fastify app in `npm test`). 3. **Signals stay coarse.** No canvas, audio, WebGL, font or IP probing, nothing sent to third parties. Anything new that is collected goes in the README table "What is collected" in the same PR. 4. **Never store raw ids or tokens.** The server keeps `bindSignals()` output (SHA-256 of ids). @@ -245,6 +285,7 @@ All rights reserved (see LICENSE). > Security primitives for both sides of MGPL Diagnostics, with zero dependencies. The browser entry collects device signals (device id, tab id, coarse fingerprint, device info) in any frontend and sends them as the x-mgpl-signals header; the server entry validates them, binds them to a session (hashed ids) and checks later requests (same / changed / other-device / missing / unbound). The server entry also has clientIp behind proxies, security and CORS headers, and log redaction. Key facts for agents: +- Works with any library: browser adapters signalFetch, mergeSignalHeaders, axiosSignals, kySignals, apolloSignals, signalStore, and collector.subscribe/getSnapshot; "./react" useDeviceSignals; server adapters requestSecurity, fetchSecurity, expressSecurity, fastifySecurity. No adapter imports its library. - Entries: "." (shared: types, encode/decode, randomId, sha256Hex, constantTimeEqual), "./browser" (createSignalCollector, deviceInfo, storages), "./server" (readSignals, bindSignals, checkSignals, decide, clientIp, securityHeaders, corsPolicy, redact, maskEmail, maskPhone). - The shared and browser parts use no Node.js APIs (checked by typecheck and scripts/check-dist.mjs). - decodeSignals / readSignals never throw and return null for anything malformed or oversized. @@ -260,6 +301,9 @@ Key facts for agents: - [src/shared/signals.ts](src/shared/signals.ts): encode, decode and validate signals - [src/browser/collect.ts](src/browser/collect.ts): createSignalCollector +- [src/browser/adapters.ts](src/browser/adapters.ts): fetch, axios, ky, Apollo, store adapters +- [src/react.ts](src/react.ts): useDeviceSignals +- [src/server/adapters.ts](src/server/adapters.ts): Express, Fastify, Fetch adapters - [src/browser/device.ts](src/browser/device.ts): device info and fingerprint inputs - [src/server/signals.ts](src/server/signals.ts): readSignals, bindSignals, checkSignals, decide - [src/server/ip.ts](src/server/ip.ts): clientIp @@ -281,8 +325,8 @@ Key facts for agents: ```json { "name": "@mgpl-diagnostics/security-kit", - "version": "0.1.0", - "description": "MGPL Diagnostics security primitives for both sides: device signals (device id, tab id, coarse fingerprint, device info) collected in any frontend and checked on the backend against the session; client IP behind proxies; security and CORS headers; log redaction; random ids, SHA-256 and constant-time compare. Zero dependencies.", + "version": "0.2.0", + "description": "MGPL Diagnostics security primitives for both sides: device signals (device id, tab id, coarse fingerprint, device info) collected in any frontend and checked on the backend against the session; client IP behind proxies; security and CORS headers; log redaction; random ids, SHA-256 and constant-time compare. Zero dependencies; works with any frontend or backend library (fetch, axios, ky, Apollo, TanStack Query, React, Vue, Svelte, Express, Fastify, Fetch-standard servers).", "type": "module", "license": "SEE LICENSE IN LICENSE", "repository": "github:mgpl-diagnostics/security-kit", @@ -302,6 +346,10 @@ Key facts for agents: "types": "./dist/server.d.ts", "import": "./dist/server.js" }, + "./react": { + "types": "./dist/react.d.ts", + "import": "./dist/react.js" + }, "./package.json": "./package.json" }, "files": [ @@ -316,20 +364,35 @@ Key facts for agents: "registry": "https://npm.pkg.github.com" }, "scripts": { - "build": "tsup", + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.browser.json", "test": "tsx scripts/test.ts", - "prepare": "tsup", + "prepare": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup", "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/security-kit README.md AGENTS.md llms.txt package.json src scripts" }, "devDependencies": { + "@types/express": "^5.0.3", "@types/node": "^22.20.5", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "express": "^5.1.0", + "fastify": "^5.6.0", + "react": "^19.3.0", + "react-dom": "^19.3.1", "tsup": "^8.5.0", "tsx": "^4.23.15", "typescript": "^5.9.3" }, "engines": { "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } } ``` @@ -360,6 +423,7 @@ function walk(file) { } walk("dist/index.js"); walk("dist/browser.js"); +walk("dist/react.js"); if (bad.length) { console.error(bad.join("\n")); process.exit(1); @@ -852,6 +916,176 @@ await check("maskEmail and maskPhone", () => { eq(maskPhone(""), ""); }); +// ---------- browser: subscribe / snapshot and library adapters ---------- + +await check("collector subscribe/getSnapshot: first collect, refresh, reset, unsubscribe", async () => { + const c = collector(env(UA.chromeWin)); + const seen: (DeviceSignals | null)[] = []; + const off = c.subscribe((s) => seen.push(s)); + eq(c.getSnapshot(), null); + const a = await c.collect(); + ok(c.getSnapshot() === a, "snapshot is the collected object"); + await c.collect(); + eq(seen.length, 1); // cached: no second notification + const b = await c.refresh(); + ok(c.getSnapshot() === b && seen.length === 2); + await c.reset(); + eq(c.getSnapshot(), null); + eq(seen.length, 3); + off(); + await c.collect(); + eq(seen.length, 3); +}); +await check("collector: a refresh during a collect wins", async () => { + let lang = "en-IN"; + const c = createSignalCollector({ storage: memoryStorage(), tabStorage: memoryStorage(), environment: () => env(UA.chromeWin, { language: lang }) }); + const first = c.collect(); + lang = "hi-IN"; + const second = c.refresh(); + await first; + eq((await second).device.language, "hi-IN"); + eq(c.getSnapshot()?.device.language, "hi-IN"); +}); + +const sk = await import("../src/browser/index"); +const recorded: { url: string; headers: Headers }[] = []; +const fakeFetch = async (input: RequestInfo | URL, init?: RequestInit) => { + recorded.push({ url: String(input instanceof Request ? input.url : input), headers: new Headers(init?.headers) }); + return new Response("{}"); +}; + +await check("signalFetch adds the header, keeps caller headers, never overwrites", async () => { + const c = collector(env(UA.chromeWin)); + const f = sk.signalFetch(c, fakeFetch); + await f("https://api.example/graphql", { headers: { authorization: "Bearer t" } }); + const h = recorded.at(-1)!.headers; + eq(decodeSignals(h.get(SIGNALS_HEADER)), await c.collect()); + eq(h.get("authorization"), "Bearer t"); + await f("https://api.example/graphql", { headers: { [SIGNALS_HEADER]: "mine" } }); + eq(recorded.at(-1)!.headers.get(SIGNALS_HEADER), "mine"); + await f(new Request("https://api.example/x", { headers: { "x-a": "1" } })); + eq(recorded.at(-1)!.headers.get("x-a"), "1"); +}); +await check("signalFetch still sends the request when collection fails", async () => { + const broken = createSignalCollector({ storage: { get: () => { throw new Error("blocked"); }, set: () => undefined }, tabStorage: memoryStorage(), environment: () => env(UA.chromeWin) }); + const errors: unknown[] = []; + const res = await sk.signalFetch(broken, fakeFetch, { onError: (e) => errors.push(e) })("https://api.example/"); + eq(res.status, 200); + eq(recorded.at(-1)!.headers.has(SIGNALS_HEADER), false); + eq(errors.length, 1); +}); +await check("mergeSignalHeaders, apolloSignals, kySignals, axiosSignals", async () => { + const c = collector(env(UA.chromeWin)); + const v = await c.headerValue(); + eq(await sk.mergeSignalHeaders(c, { Authorization: "Bearer t" }), { authorization: "Bearer t", [SIGNALS_HEADER]: v }); + eq((await sk.apolloSignals(c)({}, { headers: { authorization: "Bearer t" } })).headers[SIGNALS_HEADER], v); + const req = new Request("https://api.example/"); + await sk.kySignals(c)(req); + eq(req.headers.get(SIGNALS_HEADER), v); + // axios 1.x passes an AxiosHeaders instance (set/has); older code passes plain objects + const store = new Map(); + const axiosHeaders = Object.assign(Object.create(null), { set: (k: string, x: string) => void store.set(k, x), has: (k: string) => store.has(k) }); + await sk.axiosSignals(c)({ headers: axiosHeaders }); + eq(store.get(SIGNALS_HEADER), v); + const plain = await sk.axiosSignals(c)({ headers: { accept: "json" } as Record }); + eq(plain.headers, { accept: "json", [SIGNALS_HEADER]: v }); + eq((await sk.axiosSignals(c)({})).headers, { [SIGNALS_HEADER]: v }); +}); +await check("signalStore follows the Svelte store contract", async () => { + const c = collector(env(UA.chromeWin)); + const seen: (DeviceSignals | null)[] = []; + const off = sk.signalStore(c).subscribe((s) => seen.push(s)); + eq(seen, [null]); // called at once + await c.collect(); + eq(seen.length, 2); + ok(seen[1] === c.getSnapshot()); + off(); +}); +await check("our client SDK's headers option works directly", async () => { + const c = collector(env(UA.chromeWin)); + // createMgplGraphQLClient({ headers: () => signals.headers() }) awaits a HeadersInit function + const headersOption: () => Promise = () => c.headers(); + eq(new Headers(await headersOption()).has(SIGNALS_HEADER), true); +}); +await check("React: useDeviceSignals renders null on the server and needs no provider", async () => { + const React = await import("react"); + const { renderToString } = await import("react-dom/server"); + const { useDeviceSignals } = await import("../src/react"); + const c = collector(env(UA.chromeWin)); + await c.collect(); + const App = () => React.createElement("p", null, useDeviceSignals(c)?.device.os ?? "none"); + eq(renderToString(React.createElement(App)), "

none

"); +}); + +// ---------- server: framework adapters ---------- + +const { expressSecurity, fastifySecurity, fetchSecurity, requestSecurity } = await import("../src/server/index"); +const sigValue = encodeSignals(good); + +await check("requestSecurity", () => { + const r = requestSecurity({ headers: { "x-mgpl-signals": sigValue, "user-agent": "UA", origin: "https://a.b" }, remoteAddress: "::ffff:10.1.1.1" }); + eq(r, { ip: "10.1.1.1", signals: good, userAgent: "UA", origin: "https://a.b" }); +}); +await check("fetchSecurity: info, headers, preflight, apply", () => { + const sec = fetchSecurity({ cors: { origins: ["https://lab.example.in"] }, trustedHops: 1 }); + const r = sec.handle(new Request("https://api/graphql", { method: "POST", headers: { origin: "https://lab.example.in", "x-forwarded-for": "203.0.113.9", [SIGNALS_HEADER]: sigValue } }), "10.0.0.2"); + eq(r.security.ip, "203.0.113.9"); + eq(r.security.signals, good); + eq(r.preflight, null); + eq(r.headers["access-control-allow-origin"], "https://lab.example.in"); + eq(r.headers["x-content-type-options"], "nosniff"); + const pre = sec.handle(new Request("https://api/graphql", { method: "OPTIONS", headers: { origin: "https://lab.example.in", "access-control-request-method": "POST" } })); + eq(pre.preflight?.status, 204); + ok(pre.preflight!.headers.get("access-control-allow-headers")!.includes(SIGNALS_HEADER)); + const res = sec.apply(new Response("ok"), r.headers); + eq(res.headers.get("cache-control"), "no-store"); + const immutable = sec.apply(Response.redirect("https://x.example/", 302), r.headers); + eq(immutable.headers.get("x-frame-options"), "DENY"); +}); + +await check("expressSecurity on a real Express app", async () => { + const express = (await import("express")).default; + const app = express(); + app.use(expressSecurity({ cors: { origins: ["https://lab.example.in"] } })); + app.post("/graphql", (req, res) => void res.json((req as unknown as { security: unknown }).security)); + const server = app.listen(0); + await new Promise((r) => server.once("listening", r)); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { method: "POST", headers: { origin: "https://lab.example.in", [SIGNALS_HEADER]: sigValue } }); + const body = (await res.json()) as { ip: string; signals: DeviceSignals }; + eq(body.signals, good); + eq(body.ip, "127.0.0.1"); + eq(res.headers.get("access-control-allow-origin"), "https://lab.example.in"); + eq(res.headers.get("strict-transport-security"), "max-age=31536000; includeSubDomains"); + const pre = await fetch(`http://127.0.0.1:${port}/graphql`, { method: "OPTIONS", headers: { origin: "https://lab.example.in", "access-control-request-method": "POST" } }); + eq(pre.status, 204); + const evil = await fetch(`http://127.0.0.1:${port}/graphql`, { method: "POST", headers: { origin: "https://evil.example" } }); + eq(evil.headers.get("access-control-allow-origin"), null); + } finally { + server.close(); + } +}); +await check("fastifySecurity on a real Fastify app (applies outside its plugin scope)", async () => { + const Fastify = (await import("fastify")).default; + const app = Fastify(); + await app.register(fastifySecurity({ cors: { origins: ["https://*.mgpl.example"] }, headers: { hsts: false } })); + app.post("/graphql", async (req) => (req as unknown as { security: unknown }).security); + try { + const res = await app.inject({ method: "POST", url: "/graphql", headers: { origin: "https://panel.mgpl.example", [SIGNALS_HEADER]: sigValue }, payload: {} }); + const body = res.json() as { signals: DeviceSignals; ip: string }; + eq(body.signals, good); + eq(body.ip, "127.0.0.1"); + eq(res.headers["access-control-allow-origin"], "https://panel.mgpl.example"); + eq(res.headers["strict-transport-security"], undefined); + eq(res.headers["x-frame-options"], "DENY"); + const pre = await app.inject({ method: "OPTIONS", url: "/graphql", headers: { origin: "https://panel.mgpl.example", "access-control-request-method": "POST" } }); + eq(pre.statusCode, 204); + } finally { + await app.close(); + } +}); + console.log(`${passed} passed, ${failures.length} failed`); if (failures.length) { for (const f of failures) console.error(` ✗ ${f}`); @@ -860,6 +1094,122 @@ if (failures.length) { ``` +--- + +## File: src/browser/adapters.ts + +```ts +// Thin adapters that attach the signals header in any HTTP or GraphQL client. None imports a library: +// each matches the library's hook shape, so security-kit keeps zero dependencies. +import { SIGNALS_HEADER } from "../shared/constants"; +import type { SignalCollector } from "./collect"; + +/** What to do when signals can't be collected (no Web Crypto, storage error): by default, send without. */ +export interface AdapterOptions { + onError?: (error: unknown) => void; +} + +async function value(collector: SignalCollector, o: AdapterOptions): Promise { + try { + return await collector.headerValue(); + } catch (e) { + o.onError?.(e); + return null; + } +} + +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +/** + * A `fetch` that adds the signals header. Pass it wherever a library takes a custom fetch: + * `@mgpl-diagnostics/client` (`fetch`), graphql-request, urql (`fetch`), Apollo's HttpLink (`fetch`), + * ofetch, wretch, TanStack Query / SWR query functions, or plain code. + * A header already set by the caller is kept. + */ +export function signalFetch(collector: SignalCollector, fetchImpl?: FetchLike, o: AdapterOptions = {}): FetchLike { + return async (input, init) => { + const base = fetchImpl ?? ((i: RequestInfo | URL, n?: RequestInit) => fetch(i, n)); + const headers = new Headers(init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined)); + if (!headers.has(SIGNALS_HEADER)) { + const v = await value(collector, o); + if (v) headers.set(SIGNALS_HEADER, v); + } + return base(input, { ...init, headers }); + }; +} + +/** + * Returns `headers` plus the signals header, as a plain object. For anything that takes a headers + * object or a headers function: `@mgpl-diagnostics/client` (`headers: () => mergeSignalHeaders(signals)`), + * graphql-request's `requestMiddleware`, graphql-ws `connectionParams`, socket.io `extraHeaders`, Angular + * HttpClient interceptors. + */ +export async function mergeSignalHeaders(collector: SignalCollector, headers?: HeadersInit, o: AdapterOptions = {}): Promise> { + const out: Record = {}; + new Headers(headers).forEach((v, k) => (out[k] = v)); + if (!(SIGNALS_HEADER in out)) { + const v = await value(collector, o); + if (v) out[SIGNALS_HEADER] = v; + } + return out; +} + +interface AxiosLikeConfig { + headers?: { set?(name: string, value: string): unknown; has?(name: string): boolean } & Record; +} + +/** An axios request interceptor: `axios.interceptors.request.use(axiosSignals(signals))`. */ +export function axiosSignals(collector: SignalCollector, o: AdapterOptions = {}): (config: C) => Promise { + return async (config) => { + const v = await value(collector, o); + if (!v) return config; + const h = config.headers; + if (h && typeof h.set === "function") { + if (!h.has?.(SIGNALS_HEADER)) h.set(SIGNALS_HEADER, v); + } else { + config.headers = { ...(h ?? {}), [SIGNALS_HEADER]: (h?.[SIGNALS_HEADER] as string | undefined) ?? v }; + } + return config; + }; +} + +/** A ky `beforeRequest` hook: `ky.create({ hooks: { beforeRequest: [kySignals(signals)] } })`. */ +export function kySignals(collector: SignalCollector, o: AdapterOptions = {}): (request: Request) => Promise { + return async (request) => { + if (request.headers.has(SIGNALS_HEADER)) return; + const v = await value(collector, o); + if (v) request.headers.set(SIGNALS_HEADER, v); + }; +} + +/** + * An Apollo Client context setter: `setContext(apolloSignals(signals))` from `@apollo/client/link/context`. + * Also fits any library whose hook receives and returns `{ headers }`. + */ +export function apolloSignals(collector: SignalCollector, o: AdapterOptions = {}) { + return async (_operation: unknown, prev: { headers?: Record } = {}) => ({ + headers: { ...(prev.headers ?? {}), ...(await mergeSignalHeaders(collector, prev.headers, o)) }, + }); +} + +/** + * A readable store (the Svelte store contract: `subscribe` calls the listener at once, then on every + * change). Use it as `$signals` in Svelte, or wrap it in one line for Vue (`shallowRef` + `subscribe`), + * Solid (`from`), Angular (`toSignal` via an Observable) or RxJS. Starts collection on first subscribe. + */ +export function signalStore(collector: SignalCollector, o: AdapterOptions = {}) { + return { + subscribe(listener: (signals: ReturnType) => void): () => void { + listener(collector.getSnapshot()); + const off = collector.subscribe(listener); + if (!collector.getSnapshot()) collector.collect().catch((e) => o.onError?.(e)); + return off; + }, + }; +} +``` + + --- ## File: src/browser/collect.ts @@ -896,6 +1246,16 @@ export interface SignalCollector { refresh(): Promise; /** Forgets the device and tab ids (for "forget this device" or after a sign-out on a shared PC). */ reset(): Promise; + /** + * The last collected signals, or null before the first `collect()`. The same object until the + * signals change, so it works as a snapshot for React's useSyncExternalStore and similar. + */ + getSnapshot(): DeviceSignals | null; + /** + * Calls `listener` whenever the signals change (first collect, refresh, reset). Returns the + * unsubscribe function. Fits any reactive library: React, Vue, Svelte, Solid, Angular signals, RxJS. + */ + subscribe(listener: (signals: DeviceSignals | null) => void): () => void; } /** @@ -911,6 +1271,19 @@ export function createSignalCollector(o: SignalCollectorOptions = {}): SignalCol const prefix = o.prefix ?? "mgpl."; const env = o.environment ?? defaultEnvironment; let cached: Promise | null = null; + let snapshot: DeviceSignals | null = null; + let generation = 0; + const listeners = new Set<(signals: DeviceSignals | null) => void>(); + const publish = (next: DeviceSignals | null) => { + snapshot = next; + for (const l of [...listeners]) { + try { + l(next); + } catch { + /* a listener's error must not break collection */ + } + } + }; async function idFrom(store: SignalStorage, key: string): Promise { const existing = await store.get(prefix + key); @@ -931,10 +1304,20 @@ export function createSignalCollector(o: SignalCollectorOptions = {}): SignalCol } const collect = () => { - cached ??= build().catch((err) => { - cached = null; // retry next time - throw err; - }); + if (!cached) { + const gen = generation; + cached = build().then( + (signals) => { + // a refresh() or reset() during the build makes this result stale + if (gen === generation && signals !== snapshot) publish(signals); + return signals; + }, + (err) => { + if (gen === generation) cached = null; // retry next time + throw err; + }, + ); + } return cached; }; @@ -943,13 +1326,21 @@ export function createSignalCollector(o: SignalCollectorOptions = {}): SignalCol headers: async () => signalHeaders(await collect()), headerValue: async () => encodeSignals(await collect()), refresh() { + generation++; cached = null; return collect(); }, async reset() { + generation++; cached = null; await storage.remove?.(prefix + "deviceId"); await tabStorage.remove?.(prefix + "tabId"); + publish(null); + }, + getSnapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => void listeners.delete(listener); }, }; } @@ -1075,6 +1466,7 @@ export function fingerprintComponents(env: SignalEnvironment = defaultEnvironmen ```ts // "@mgpl-diagnostics/security-kit/browser": collects device signals in any frontend. No Node.js APIs. +export { apolloSignals, axiosSignals, kySignals, mergeSignalHeaders, signalFetch, signalStore, type AdapterOptions } from "./adapters"; export { createSignalCollector, type SignalCollector, type SignalCollectorOptions } from "./collect"; export { browserOf, defaultEnvironment, deviceInfo, fingerprintComponents, type SignalEnvironment } from "./device"; export { memoryStorage, webStorage, type SignalStorage } from "./storage"; @@ -1158,6 +1550,197 @@ export { decodeSignals, encodeSignals, parseDeviceInfo, parseSignals, signalHead ``` +--- + +## File: src/react.ts + +```ts +// "@mgpl-diagnostics/security-kit/react": one optional hook. React is an optional peer dependency; +// every other framework uses the collector's subscribe/getSnapshot or signalStore() directly. +import { useEffect, useSyncExternalStore } from "react"; +import type { SignalCollector } from "./browser/collect"; +import type { DeviceSignals } from "./shared/types"; + +/** + * The collector's current signals (null until collected, and during server rendering). Starts + * collection on mount. Pass a collector created once at module level. + */ +export function useDeviceSignals(collector: SignalCollector): DeviceSignals | null { + const signals = useSyncExternalStore(collector.subscribe, collector.getSnapshot, () => null); + useEffect(() => { + if (!collector.getSnapshot()) collector.collect().catch(() => undefined); + }, [collector]); + return signals; +} +``` + + +--- + +## File: src/server/adapters.ts + +```ts +// Thin server adapters. None imports a framework: each matches its hook shape structurally. +// any framework / GraphQL context → requestSecurity() +// Fetch-standard (Hono, Next.js route handlers, Bun, Deno, GraphQL Yoga, workers) → fetchSecurity() +// Express / Connect / node:http → expressSecurity() +// Fastify → app.register(fastifySecurity()) +import type { DeviceSignals } from "../shared/types"; +import { corsPolicy, securityHeaders, type CorsOptions, type SecurityHeaderOptions } from "./headers"; +import { clientIp } from "./ip"; +import { headerValue, readSignals, type HeaderSource } from "./signals"; + +export interface RequestSecurity { + /** Client IP (see clientIp and `trustedHops`). */ + ip: string | null; + /** Validated device signals, or null. */ + signals: DeviceSignals | null; + userAgent: string | null; + origin: string | null; +} + +export interface SecurityOptions { + /** Response security headers; false to leave them to someone else. Default securityHeaders(). */ + headers?: SecurityHeaderOptions | false; + /** CORS; omit or false to leave CORS to someone else. */ + cors?: CorsOptions | false; + /** Reverse proxies that append to X-Forwarded-For. Default 0. */ + trustedHops?: number; +} + +/** IP, signals, user agent and origin of any request. Use it to build a GraphQL context. */ +export function requestSecurity(input: { headers?: HeaderSource; remoteAddress?: string | null; trustedHops?: number }): RequestSecurity { + return { + ip: clientIp(input), + signals: readSignals(input.headers), + userAgent: headerValue(input.headers, "user-agent")?.slice(0, 512) ?? null, + origin: headerValue(input.headers, "origin") ?? null, + }; +} + +function prepare(o: SecurityOptions) { + const base = o.headers === false ? {} : securityHeaders(o.headers ?? {}); + const cors = o.cors ? corsPolicy(o.cors) : null; + return { + /** Response headers for this request. */ + headers(origin: string | null | undefined, preflight: boolean): Record { + return { ...base, ...(cors ? cors.headers(origin, preflight) : {}) }; + }, + /** A CORS preflight this adapter answers itself (only when CORS is configured). */ + isPreflight(method: string | undefined, headers: HeaderSource | undefined): boolean { + return !!cors && method?.toUpperCase() === "OPTIONS" && !!headerValue(headers, "access-control-request-method"); + }, + }; +} + +/** + * For Fetch-standard servers. `handle(request, remoteAddress)` returns the request's security info, + * the headers to add to the response, and a ready 204 `preflight` response for CORS preflights. + * + * const sec = fetchSecurity({ cors: { origins } }); + * const { security, headers, preflight } = sec.handle(request, ip); + * if (preflight) return preflight; + */ +export function fetchSecurity(o: SecurityOptions = {}) { + const p = prepare(o); + return { + handle(request: Request, remoteAddress?: string | null) { + const security = requestSecurity({ headers: request.headers, remoteAddress, trustedHops: o.trustedHops }); + const pre = p.isPreflight(request.method, request.headers); + const headers = p.headers(security.origin, pre); + return { security, headers, preflight: pre ? new Response(null, { status: 204, headers }) : null }; + }, + /** Copies the headers onto a response (returns a new Response when the original's are immutable). */ + apply(response: Response, headers: Record): Response { + try { + for (const [k, v] of Object.entries(headers)) response.headers.set(k, v); + return response; + } catch { + const copy = new Response(response.body, response); + for (const [k, v] of Object.entries(headers)) copy.headers.set(k, v); + return copy; + } + }, + }; +} + +interface NodeLikeRequest { + method?: string; + headers: Record; + socket?: { remoteAddress?: string | null }; + security?: RequestSecurity; +} +interface NodeLikeResponse { + setHeader(name: string, value: string): unknown; + statusCode: number; + end(): unknown; +} + +/** + * Express / Connect / node:http middleware: sets the headers, answers CORS preflights with 204, and puts + * the security info on `req.security`. `app.use(expressSecurity({ cors: { origins }, trustedHops: 1 }))`. + */ +export function expressSecurity(o: SecurityOptions = {}) { + const p = prepare(o); + return (req: NodeLikeRequest, res: NodeLikeResponse, next: (err?: unknown) => void) => { + const security = requestSecurity({ headers: req.headers, remoteAddress: req.socket?.remoteAddress, trustedHops: o.trustedHops }); + req.security = security; + const pre = p.isPreflight(req.method, req.headers); + for (const [k, v] of Object.entries(p.headers(security.origin, pre))) res.setHeader(k, v); + if (pre) { + res.statusCode = 204; + res.end(); + return; + } + next(); + }; +} + +interface FastifyLikeInstance { + decorateRequest(name: string, value: unknown): unknown; + addHook(name: "onRequest", hook: (request: FastifyLikeRequest, reply: FastifyLikeReply) => Promise): unknown; +} +interface FastifyLikeRequest { + method: string; + headers: Record; + socket?: { remoteAddress?: string | null }; + raw?: { socket?: { remoteAddress?: string | null } }; + security?: RequestSecurity | null; +} +interface FastifyLikeReply { + header(name: string, value: string): unknown; + code(status: number): FastifyLikeReply; + send(payload?: unknown): unknown; +} + +/** + * A Fastify plugin (applies to the whole app, like fastify-plugin): sets the headers, answers CORS + * preflights, and decorates `request.security`. `await app.register(fastifySecurity({ trustedHops: 1 }))`. + * Leave `cors` unset when @fastify/cors already handles CORS. + */ +export function fastifySecurity(o: SecurityOptions = {}) { + const p = prepare(o); + const plugin = (app: FastifyLikeInstance, _opts: unknown, done: (err?: Error) => void) => { + app.decorateRequest("security", null); + app.addHook("onRequest", async (request, reply) => { + const remoteAddress = request.socket?.remoteAddress ?? request.raw?.socket?.remoteAddress; + const security = requestSecurity({ headers: request.headers, remoteAddress, trustedHops: o.trustedHops }); + request.security = security; + const pre = p.isPreflight(request.method, request.headers); + for (const [k, v] of Object.entries(p.headers(security.origin, pre))) reply.header(k, v); + if (pre) return reply.code(204).send(); + return undefined; + }); + done(); + }; + // what fastify-plugin sets, so hooks and the decorator apply outside the plugin's own scope + (plugin as unknown as Record)[Symbol.for("skip-override")] = true; + (plugin as unknown as Record)[Symbol.for("fastify.display-name")] = "mgpl-security"; + return plugin; +} +``` + + --- ## File: src/server/headers.ts @@ -1279,6 +1862,7 @@ export { clientIp, normalizeIp, type ClientIpOptions } from "./ip"; export { API_CSP, corsPolicy, securityHeaders, type CorsOptions, type SecurityHeaderOptions } from "./headers"; export { maskEmail, maskPhone, REDACTED, redact, SENSITIVE_KEYS, type RedactOptions } from "./redact"; export * from "../index"; +export { expressSecurity, fastifySecurity, fetchSecurity, requestSecurity, type RequestSecurity, type SecurityOptions } from "./adapters"; ``` diff --git a/llms.txt b/llms.txt index 8592891..22a7b61 100644 --- a/llms.txt +++ b/llms.txt @@ -3,6 +3,7 @@ > Security primitives for both sides of MGPL Diagnostics, with zero dependencies. The browser entry collects device signals (device id, tab id, coarse fingerprint, device info) in any frontend and sends them as the x-mgpl-signals header; the server entry validates them, binds them to a session (hashed ids) and checks later requests (same / changed / other-device / missing / unbound). The server entry also has clientIp behind proxies, security and CORS headers, and log redaction. Key facts for agents: +- Works with any library: browser adapters signalFetch, mergeSignalHeaders, axiosSignals, kySignals, apolloSignals, signalStore, and collector.subscribe/getSnapshot; "./react" useDeviceSignals; server adapters requestSecurity, fetchSecurity, expressSecurity, fastifySecurity. No adapter imports its library. - Entries: "." (shared: types, encode/decode, randomId, sha256Hex, constantTimeEqual), "./browser" (createSignalCollector, deviceInfo, storages), "./server" (readSignals, bindSignals, checkSignals, decide, clientIp, securityHeaders, corsPolicy, redact, maskEmail, maskPhone). - The shared and browser parts use no Node.js APIs (checked by typecheck and scripts/check-dist.mjs). - decodeSignals / readSignals never throw and return null for anything malformed or oversized. @@ -18,6 +19,9 @@ Key facts for agents: - [src/shared/signals.ts](src/shared/signals.ts): encode, decode and validate signals - [src/browser/collect.ts](src/browser/collect.ts): createSignalCollector +- [src/browser/adapters.ts](src/browser/adapters.ts): fetch, axios, ky, Apollo, store adapters +- [src/react.ts](src/react.ts): useDeviceSignals +- [src/server/adapters.ts](src/server/adapters.ts): Express, Fastify, Fetch adapters - [src/browser/device.ts](src/browser/device.ts): device info and fingerprint inputs - [src/server/signals.ts](src/server/signals.ts): readSignals, bindSignals, checkSignals, decide - [src/server/ip.ts](src/server/ip.ts): clientIp diff --git a/package.json b/package.json index ef5716d..e05c2c1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mgpl-diagnostics/security-kit", - "version": "0.1.0", - "description": "MGPL Diagnostics security primitives for both sides: device signals (device id, tab id, coarse fingerprint, device info) collected in any frontend and checked on the backend against the session; client IP behind proxies; security and CORS headers; log redaction; random ids, SHA-256 and constant-time compare. Zero dependencies.", + "version": "0.2.0", + "description": "MGPL Diagnostics security primitives for both sides: device signals (device id, tab id, coarse fingerprint, device info) collected in any frontend and checked on the backend against the session; client IP behind proxies; security and CORS headers; log redaction; random ids, SHA-256 and constant-time compare. Zero dependencies; works with any frontend or backend library (fetch, axios, ky, Apollo, TanStack Query, React, Vue, Svelte, Express, Fastify, Fetch-standard servers).", "type": "module", "license": "SEE LICENSE IN LICENSE", "repository": "github:mgpl-diagnostics/security-kit", @@ -21,6 +21,10 @@ "types": "./dist/server.d.ts", "import": "./dist/server.js" }, + "./react": { + "types": "./dist/react.d.ts", + "import": "./dist/react.js" + }, "./package.json": "./package.json" }, "files": [ @@ -35,19 +39,34 @@ "registry": "https://npm.pkg.github.com" }, "scripts": { - "build": "tsup", + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.browser.json", "test": "tsx scripts/test.ts", - "prepare": "tsup", + "prepare": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup", "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/security-kit README.md AGENTS.md llms.txt package.json src scripts" }, "devDependencies": { + "@types/express": "^5.0.3", "@types/node": "^22.20.5", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "express": "^5.1.0", + "fastify": "^5.6.0", + "react": "^19.3.0", + "react-dom": "^19.3.1", "tsup": "^8.5.0", "tsx": "^4.23.15", "typescript": "^5.9.3" }, "engines": { "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } } diff --git a/scripts/check-dist.mjs b/scripts/check-dist.mjs index 5e3b947..a463b71 100644 --- a/scripts/check-dist.mjs +++ b/scripts/check-dist.mjs @@ -18,6 +18,7 @@ function walk(file) { } walk("dist/index.js"); walk("dist/browser.js"); +walk("dist/react.js"); if (bad.length) { console.error(bad.join("\n")); process.exit(1); diff --git a/scripts/test.ts b/scripts/test.ts index c9f7edc..b70b60f 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -416,6 +416,176 @@ await check("maskEmail and maskPhone", () => { eq(maskPhone(""), ""); }); +// ---------- browser: subscribe / snapshot and library adapters ---------- + +await check("collector subscribe/getSnapshot: first collect, refresh, reset, unsubscribe", async () => { + const c = collector(env(UA.chromeWin)); + const seen: (DeviceSignals | null)[] = []; + const off = c.subscribe((s) => seen.push(s)); + eq(c.getSnapshot(), null); + const a = await c.collect(); + ok(c.getSnapshot() === a, "snapshot is the collected object"); + await c.collect(); + eq(seen.length, 1); // cached: no second notification + const b = await c.refresh(); + ok(c.getSnapshot() === b && seen.length === 2); + await c.reset(); + eq(c.getSnapshot(), null); + eq(seen.length, 3); + off(); + await c.collect(); + eq(seen.length, 3); +}); +await check("collector: a refresh during a collect wins", async () => { + let lang = "en-IN"; + const c = createSignalCollector({ storage: memoryStorage(), tabStorage: memoryStorage(), environment: () => env(UA.chromeWin, { language: lang }) }); + const first = c.collect(); + lang = "hi-IN"; + const second = c.refresh(); + await first; + eq((await second).device.language, "hi-IN"); + eq(c.getSnapshot()?.device.language, "hi-IN"); +}); + +const sk = await import("../src/browser/index"); +const recorded: { url: string; headers: Headers }[] = []; +const fakeFetch = async (input: RequestInfo | URL, init?: RequestInit) => { + recorded.push({ url: String(input instanceof Request ? input.url : input), headers: new Headers(init?.headers) }); + return new Response("{}"); +}; + +await check("signalFetch adds the header, keeps caller headers, never overwrites", async () => { + const c = collector(env(UA.chromeWin)); + const f = sk.signalFetch(c, fakeFetch); + await f("https://api.example/graphql", { headers: { authorization: "Bearer t" } }); + const h = recorded.at(-1)!.headers; + eq(decodeSignals(h.get(SIGNALS_HEADER)), await c.collect()); + eq(h.get("authorization"), "Bearer t"); + await f("https://api.example/graphql", { headers: { [SIGNALS_HEADER]: "mine" } }); + eq(recorded.at(-1)!.headers.get(SIGNALS_HEADER), "mine"); + await f(new Request("https://api.example/x", { headers: { "x-a": "1" } })); + eq(recorded.at(-1)!.headers.get("x-a"), "1"); +}); +await check("signalFetch still sends the request when collection fails", async () => { + const broken = createSignalCollector({ storage: { get: () => { throw new Error("blocked"); }, set: () => undefined }, tabStorage: memoryStorage(), environment: () => env(UA.chromeWin) }); + const errors: unknown[] = []; + const res = await sk.signalFetch(broken, fakeFetch, { onError: (e) => errors.push(e) })("https://api.example/"); + eq(res.status, 200); + eq(recorded.at(-1)!.headers.has(SIGNALS_HEADER), false); + eq(errors.length, 1); +}); +await check("mergeSignalHeaders, apolloSignals, kySignals, axiosSignals", async () => { + const c = collector(env(UA.chromeWin)); + const v = await c.headerValue(); + eq(await sk.mergeSignalHeaders(c, { Authorization: "Bearer t" }), { authorization: "Bearer t", [SIGNALS_HEADER]: v }); + eq((await sk.apolloSignals(c)({}, { headers: { authorization: "Bearer t" } })).headers[SIGNALS_HEADER], v); + const req = new Request("https://api.example/"); + await sk.kySignals(c)(req); + eq(req.headers.get(SIGNALS_HEADER), v); + // axios 1.x passes an AxiosHeaders instance (set/has); older code passes plain objects + const store = new Map(); + const axiosHeaders = Object.assign(Object.create(null), { set: (k: string, x: string) => void store.set(k, x), has: (k: string) => store.has(k) }); + await sk.axiosSignals(c)({ headers: axiosHeaders }); + eq(store.get(SIGNALS_HEADER), v); + const plain = await sk.axiosSignals(c)({ headers: { accept: "json" } as Record }); + eq(plain.headers, { accept: "json", [SIGNALS_HEADER]: v }); + eq((await sk.axiosSignals(c)({})).headers, { [SIGNALS_HEADER]: v }); +}); +await check("signalStore follows the Svelte store contract", async () => { + const c = collector(env(UA.chromeWin)); + const seen: (DeviceSignals | null)[] = []; + const off = sk.signalStore(c).subscribe((s) => seen.push(s)); + eq(seen, [null]); // called at once + await c.collect(); + eq(seen.length, 2); + ok(seen[1] === c.getSnapshot()); + off(); +}); +await check("our client SDK's headers option works directly", async () => { + const c = collector(env(UA.chromeWin)); + // createMgplGraphQLClient({ headers: () => signals.headers() }) awaits a HeadersInit function + const headersOption: () => Promise = () => c.headers(); + eq(new Headers(await headersOption()).has(SIGNALS_HEADER), true); +}); +await check("React: useDeviceSignals renders null on the server and needs no provider", async () => { + const React = await import("react"); + const { renderToString } = await import("react-dom/server"); + const { useDeviceSignals } = await import("../src/react"); + const c = collector(env(UA.chromeWin)); + await c.collect(); + const App = () => React.createElement("p", null, useDeviceSignals(c)?.device.os ?? "none"); + eq(renderToString(React.createElement(App)), "

none

"); +}); + +// ---------- server: framework adapters ---------- + +const { expressSecurity, fastifySecurity, fetchSecurity, requestSecurity } = await import("../src/server/index"); +const sigValue = encodeSignals(good); + +await check("requestSecurity", () => { + const r = requestSecurity({ headers: { "x-mgpl-signals": sigValue, "user-agent": "UA", origin: "https://a.b" }, remoteAddress: "::ffff:10.1.1.1" }); + eq(r, { ip: "10.1.1.1", signals: good, userAgent: "UA", origin: "https://a.b" }); +}); +await check("fetchSecurity: info, headers, preflight, apply", () => { + const sec = fetchSecurity({ cors: { origins: ["https://lab.example.in"] }, trustedHops: 1 }); + const r = sec.handle(new Request("https://api/graphql", { method: "POST", headers: { origin: "https://lab.example.in", "x-forwarded-for": "203.0.113.9", [SIGNALS_HEADER]: sigValue } }), "10.0.0.2"); + eq(r.security.ip, "203.0.113.9"); + eq(r.security.signals, good); + eq(r.preflight, null); + eq(r.headers["access-control-allow-origin"], "https://lab.example.in"); + eq(r.headers["x-content-type-options"], "nosniff"); + const pre = sec.handle(new Request("https://api/graphql", { method: "OPTIONS", headers: { origin: "https://lab.example.in", "access-control-request-method": "POST" } })); + eq(pre.preflight?.status, 204); + ok(pre.preflight!.headers.get("access-control-allow-headers")!.includes(SIGNALS_HEADER)); + const res = sec.apply(new Response("ok"), r.headers); + eq(res.headers.get("cache-control"), "no-store"); + const immutable = sec.apply(Response.redirect("https://x.example/", 302), r.headers); + eq(immutable.headers.get("x-frame-options"), "DENY"); +}); + +await check("expressSecurity on a real Express app", async () => { + const express = (await import("express")).default; + const app = express(); + app.use(expressSecurity({ cors: { origins: ["https://lab.example.in"] } })); + app.post("/graphql", (req, res) => void res.json((req as unknown as { security: unknown }).security)); + const server = app.listen(0); + await new Promise((r) => server.once("listening", r)); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}/graphql`, { method: "POST", headers: { origin: "https://lab.example.in", [SIGNALS_HEADER]: sigValue } }); + const body = (await res.json()) as { ip: string; signals: DeviceSignals }; + eq(body.signals, good); + eq(body.ip, "127.0.0.1"); + eq(res.headers.get("access-control-allow-origin"), "https://lab.example.in"); + eq(res.headers.get("strict-transport-security"), "max-age=31536000; includeSubDomains"); + const pre = await fetch(`http://127.0.0.1:${port}/graphql`, { method: "OPTIONS", headers: { origin: "https://lab.example.in", "access-control-request-method": "POST" } }); + eq(pre.status, 204); + const evil = await fetch(`http://127.0.0.1:${port}/graphql`, { method: "POST", headers: { origin: "https://evil.example" } }); + eq(evil.headers.get("access-control-allow-origin"), null); + } finally { + server.close(); + } +}); +await check("fastifySecurity on a real Fastify app (applies outside its plugin scope)", async () => { + const Fastify = (await import("fastify")).default; + const app = Fastify(); + await app.register(fastifySecurity({ cors: { origins: ["https://*.mgpl.example"] }, headers: { hsts: false } })); + app.post("/graphql", async (req) => (req as unknown as { security: unknown }).security); + try { + const res = await app.inject({ method: "POST", url: "/graphql", headers: { origin: "https://panel.mgpl.example", [SIGNALS_HEADER]: sigValue }, payload: {} }); + const body = res.json() as { signals: DeviceSignals; ip: string }; + eq(body.signals, good); + eq(body.ip, "127.0.0.1"); + eq(res.headers["access-control-allow-origin"], "https://panel.mgpl.example"); + eq(res.headers["strict-transport-security"], undefined); + eq(res.headers["x-frame-options"], "DENY"); + const pre = await app.inject({ method: "OPTIONS", url: "/graphql", headers: { origin: "https://panel.mgpl.example", "access-control-request-method": "POST" } }); + eq(pre.statusCode, 204); + } finally { + await app.close(); + } +}); + console.log(`${passed} passed, ${failures.length} failed`); if (failures.length) { for (const f of failures) console.error(` ✗ ${f}`); diff --git a/src/browser/adapters.ts b/src/browser/adapters.ts new file mode 100644 index 0000000..8a42d03 --- /dev/null +++ b/src/browser/adapters.ts @@ -0,0 +1,108 @@ +// Thin adapters that attach the signals header in any HTTP or GraphQL client. None imports a library: +// each matches the library's hook shape, so security-kit keeps zero dependencies. +import { SIGNALS_HEADER } from "../shared/constants"; +import type { SignalCollector } from "./collect"; + +/** What to do when signals can't be collected (no Web Crypto, storage error): by default, send without. */ +export interface AdapterOptions { + onError?: (error: unknown) => void; +} + +async function value(collector: SignalCollector, o: AdapterOptions): Promise { + try { + return await collector.headerValue(); + } catch (e) { + o.onError?.(e); + return null; + } +} + +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +/** + * A `fetch` that adds the signals header. Pass it wherever a library takes a custom fetch: + * `@mgpl-diagnostics/client` (`fetch`), graphql-request, urql (`fetch`), Apollo's HttpLink (`fetch`), + * ofetch, wretch, TanStack Query / SWR query functions, or plain code. + * A header already set by the caller is kept. + */ +export function signalFetch(collector: SignalCollector, fetchImpl?: FetchLike, o: AdapterOptions = {}): FetchLike { + return async (input, init) => { + const base = fetchImpl ?? ((i: RequestInfo | URL, n?: RequestInit) => fetch(i, n)); + const headers = new Headers(init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined)); + if (!headers.has(SIGNALS_HEADER)) { + const v = await value(collector, o); + if (v) headers.set(SIGNALS_HEADER, v); + } + return base(input, { ...init, headers }); + }; +} + +/** + * Returns `headers` plus the signals header, as a plain object. For anything that takes a headers + * object or a headers function: `@mgpl-diagnostics/client` (`headers: () => mergeSignalHeaders(signals)`), + * graphql-request's `requestMiddleware`, graphql-ws `connectionParams`, socket.io `extraHeaders`, Angular + * HttpClient interceptors. + */ +export async function mergeSignalHeaders(collector: SignalCollector, headers?: HeadersInit, o: AdapterOptions = {}): Promise> { + const out: Record = {}; + new Headers(headers).forEach((v, k) => (out[k] = v)); + if (!(SIGNALS_HEADER in out)) { + const v = await value(collector, o); + if (v) out[SIGNALS_HEADER] = v; + } + return out; +} + +interface AxiosLikeConfig { + headers?: { set?(name: string, value: string): unknown; has?(name: string): boolean } & Record; +} + +/** An axios request interceptor: `axios.interceptors.request.use(axiosSignals(signals))`. */ +export function axiosSignals(collector: SignalCollector, o: AdapterOptions = {}): (config: C) => Promise { + return async (config) => { + const v = await value(collector, o); + if (!v) return config; + const h = config.headers; + if (h && typeof h.set === "function") { + if (!h.has?.(SIGNALS_HEADER)) h.set(SIGNALS_HEADER, v); + } else { + config.headers = { ...(h ?? {}), [SIGNALS_HEADER]: (h?.[SIGNALS_HEADER] as string | undefined) ?? v }; + } + return config; + }; +} + +/** A ky `beforeRequest` hook: `ky.create({ hooks: { beforeRequest: [kySignals(signals)] } })`. */ +export function kySignals(collector: SignalCollector, o: AdapterOptions = {}): (request: Request) => Promise { + return async (request) => { + if (request.headers.has(SIGNALS_HEADER)) return; + const v = await value(collector, o); + if (v) request.headers.set(SIGNALS_HEADER, v); + }; +} + +/** + * An Apollo Client context setter: `setContext(apolloSignals(signals))` from `@apollo/client/link/context`. + * Also fits any library whose hook receives and returns `{ headers }`. + */ +export function apolloSignals(collector: SignalCollector, o: AdapterOptions = {}) { + return async (_operation: unknown, prev: { headers?: Record } = {}) => ({ + headers: { ...(prev.headers ?? {}), ...(await mergeSignalHeaders(collector, prev.headers, o)) }, + }); +} + +/** + * A readable store (the Svelte store contract: `subscribe` calls the listener at once, then on every + * change). Use it as `$signals` in Svelte, or wrap it in one line for Vue (`shallowRef` + `subscribe`), + * Solid (`from`), Angular (`toSignal` via an Observable) or RxJS. Starts collection on first subscribe. + */ +export function signalStore(collector: SignalCollector, o: AdapterOptions = {}) { + return { + subscribe(listener: (signals: ReturnType) => void): () => void { + listener(collector.getSnapshot()); + const off = collector.subscribe(listener); + if (!collector.getSnapshot()) collector.collect().catch((e) => o.onError?.(e)); + return off; + }, + }; +} diff --git a/src/browser/collect.ts b/src/browser/collect.ts index 687245d..6c9334c 100644 --- a/src/browser/collect.ts +++ b/src/browser/collect.ts @@ -29,6 +29,16 @@ export interface SignalCollector { refresh(): Promise; /** Forgets the device and tab ids (for "forget this device" or after a sign-out on a shared PC). */ reset(): Promise; + /** + * The last collected signals, or null before the first `collect()`. The same object until the + * signals change, so it works as a snapshot for React's useSyncExternalStore and similar. + */ + getSnapshot(): DeviceSignals | null; + /** + * Calls `listener` whenever the signals change (first collect, refresh, reset). Returns the + * unsubscribe function. Fits any reactive library: React, Vue, Svelte, Solid, Angular signals, RxJS. + */ + subscribe(listener: (signals: DeviceSignals | null) => void): () => void; } /** @@ -44,6 +54,19 @@ export function createSignalCollector(o: SignalCollectorOptions = {}): SignalCol const prefix = o.prefix ?? "mgpl."; const env = o.environment ?? defaultEnvironment; let cached: Promise | null = null; + let snapshot: DeviceSignals | null = null; + let generation = 0; + const listeners = new Set<(signals: DeviceSignals | null) => void>(); + const publish = (next: DeviceSignals | null) => { + snapshot = next; + for (const l of [...listeners]) { + try { + l(next); + } catch { + /* a listener's error must not break collection */ + } + } + }; async function idFrom(store: SignalStorage, key: string): Promise { const existing = await store.get(prefix + key); @@ -64,10 +87,20 @@ export function createSignalCollector(o: SignalCollectorOptions = {}): SignalCol } const collect = () => { - cached ??= build().catch((err) => { - cached = null; // retry next time - throw err; - }); + if (!cached) { + const gen = generation; + cached = build().then( + (signals) => { + // a refresh() or reset() during the build makes this result stale + if (gen === generation && signals !== snapshot) publish(signals); + return signals; + }, + (err) => { + if (gen === generation) cached = null; // retry next time + throw err; + }, + ); + } return cached; }; @@ -76,13 +109,21 @@ export function createSignalCollector(o: SignalCollectorOptions = {}): SignalCol headers: async () => signalHeaders(await collect()), headerValue: async () => encodeSignals(await collect()), refresh() { + generation++; cached = null; return collect(); }, async reset() { + generation++; cached = null; await storage.remove?.(prefix + "deviceId"); await tabStorage.remove?.(prefix + "tabId"); + publish(null); + }, + getSnapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => void listeners.delete(listener); }, }; } diff --git a/src/browser/index.ts b/src/browser/index.ts index 991547d..933c287 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -1,4 +1,5 @@ // "@mgpl-diagnostics/security-kit/browser": collects device signals in any frontend. No Node.js APIs. +export { apolloSignals, axiosSignals, kySignals, mergeSignalHeaders, signalFetch, signalStore, type AdapterOptions } from "./adapters"; export { createSignalCollector, type SignalCollector, type SignalCollectorOptions } from "./collect"; export { browserOf, defaultEnvironment, deviceInfo, fingerprintComponents, type SignalEnvironment } from "./device"; export { memoryStorage, webStorage, type SignalStorage } from "./storage"; diff --git a/src/react.ts b/src/react.ts new file mode 100644 index 0000000..6a59c25 --- /dev/null +++ b/src/react.ts @@ -0,0 +1,17 @@ +// "@mgpl-diagnostics/security-kit/react": one optional hook. React is an optional peer dependency; +// every other framework uses the collector's subscribe/getSnapshot or signalStore() directly. +import { useEffect, useSyncExternalStore } from "react"; +import type { SignalCollector } from "./browser/collect"; +import type { DeviceSignals } from "./shared/types"; + +/** + * The collector's current signals (null until collected, and during server rendering). Starts + * collection on mount. Pass a collector created once at module level. + */ +export function useDeviceSignals(collector: SignalCollector): DeviceSignals | null { + const signals = useSyncExternalStore(collector.subscribe, collector.getSnapshot, () => null); + useEffect(() => { + if (!collector.getSnapshot()) collector.collect().catch(() => undefined); + }, [collector]); + return signals; +} diff --git a/src/server/adapters.ts b/src/server/adapters.ts new file mode 100644 index 0000000..95568f9 --- /dev/null +++ b/src/server/adapters.ts @@ -0,0 +1,158 @@ +// Thin server adapters. None imports a framework: each matches its hook shape structurally. +// any framework / GraphQL context → requestSecurity() +// Fetch-standard (Hono, Next.js route handlers, Bun, Deno, GraphQL Yoga, workers) → fetchSecurity() +// Express / Connect / node:http → expressSecurity() +// Fastify → app.register(fastifySecurity()) +import type { DeviceSignals } from "../shared/types"; +import { corsPolicy, securityHeaders, type CorsOptions, type SecurityHeaderOptions } from "./headers"; +import { clientIp } from "./ip"; +import { headerValue, readSignals, type HeaderSource } from "./signals"; + +export interface RequestSecurity { + /** Client IP (see clientIp and `trustedHops`). */ + ip: string | null; + /** Validated device signals, or null. */ + signals: DeviceSignals | null; + userAgent: string | null; + origin: string | null; +} + +export interface SecurityOptions { + /** Response security headers; false to leave them to someone else. Default securityHeaders(). */ + headers?: SecurityHeaderOptions | false; + /** CORS; omit or false to leave CORS to someone else. */ + cors?: CorsOptions | false; + /** Reverse proxies that append to X-Forwarded-For. Default 0. */ + trustedHops?: number; +} + +/** IP, signals, user agent and origin of any request. Use it to build a GraphQL context. */ +export function requestSecurity(input: { headers?: HeaderSource; remoteAddress?: string | null; trustedHops?: number }): RequestSecurity { + return { + ip: clientIp(input), + signals: readSignals(input.headers), + userAgent: headerValue(input.headers, "user-agent")?.slice(0, 512) ?? null, + origin: headerValue(input.headers, "origin") ?? null, + }; +} + +function prepare(o: SecurityOptions) { + const base = o.headers === false ? {} : securityHeaders(o.headers ?? {}); + const cors = o.cors ? corsPolicy(o.cors) : null; + return { + /** Response headers for this request. */ + headers(origin: string | null | undefined, preflight: boolean): Record { + return { ...base, ...(cors ? cors.headers(origin, preflight) : {}) }; + }, + /** A CORS preflight this adapter answers itself (only when CORS is configured). */ + isPreflight(method: string | undefined, headers: HeaderSource | undefined): boolean { + return !!cors && method?.toUpperCase() === "OPTIONS" && !!headerValue(headers, "access-control-request-method"); + }, + }; +} + +/** + * For Fetch-standard servers. `handle(request, remoteAddress)` returns the request's security info, + * the headers to add to the response, and a ready 204 `preflight` response for CORS preflights. + * + * const sec = fetchSecurity({ cors: { origins } }); + * const { security, headers, preflight } = sec.handle(request, ip); + * if (preflight) return preflight; + */ +export function fetchSecurity(o: SecurityOptions = {}) { + const p = prepare(o); + return { + handle(request: Request, remoteAddress?: string | null) { + const security = requestSecurity({ headers: request.headers, remoteAddress, trustedHops: o.trustedHops }); + const pre = p.isPreflight(request.method, request.headers); + const headers = p.headers(security.origin, pre); + return { security, headers, preflight: pre ? new Response(null, { status: 204, headers }) : null }; + }, + /** Copies the headers onto a response (returns a new Response when the original's are immutable). */ + apply(response: Response, headers: Record): Response { + try { + for (const [k, v] of Object.entries(headers)) response.headers.set(k, v); + return response; + } catch { + const copy = new Response(response.body, response); + for (const [k, v] of Object.entries(headers)) copy.headers.set(k, v); + return copy; + } + }, + }; +} + +interface NodeLikeRequest { + method?: string; + headers: Record; + socket?: { remoteAddress?: string | null }; + security?: RequestSecurity; +} +interface NodeLikeResponse { + setHeader(name: string, value: string): unknown; + statusCode: number; + end(): unknown; +} + +/** + * Express / Connect / node:http middleware: sets the headers, answers CORS preflights with 204, and puts + * the security info on `req.security`. `app.use(expressSecurity({ cors: { origins }, trustedHops: 1 }))`. + */ +export function expressSecurity(o: SecurityOptions = {}) { + const p = prepare(o); + return (req: NodeLikeRequest, res: NodeLikeResponse, next: (err?: unknown) => void) => { + const security = requestSecurity({ headers: req.headers, remoteAddress: req.socket?.remoteAddress, trustedHops: o.trustedHops }); + req.security = security; + const pre = p.isPreflight(req.method, req.headers); + for (const [k, v] of Object.entries(p.headers(security.origin, pre))) res.setHeader(k, v); + if (pre) { + res.statusCode = 204; + res.end(); + return; + } + next(); + }; +} + +interface FastifyLikeInstance { + decorateRequest(name: string, value: unknown): unknown; + addHook(name: "onRequest", hook: (request: FastifyLikeRequest, reply: FastifyLikeReply) => Promise): unknown; +} +interface FastifyLikeRequest { + method: string; + headers: Record; + socket?: { remoteAddress?: string | null }; + raw?: { socket?: { remoteAddress?: string | null } }; + security?: RequestSecurity | null; +} +interface FastifyLikeReply { + header(name: string, value: string): unknown; + code(status: number): FastifyLikeReply; + send(payload?: unknown): unknown; +} + +/** + * A Fastify plugin (applies to the whole app, like fastify-plugin): sets the headers, answers CORS + * preflights, and decorates `request.security`. `await app.register(fastifySecurity({ trustedHops: 1 }))`. + * Leave `cors` unset when @fastify/cors already handles CORS. + */ +export function fastifySecurity(o: SecurityOptions = {}) { + const p = prepare(o); + const plugin = (app: FastifyLikeInstance, _opts: unknown, done: (err?: Error) => void) => { + app.decorateRequest("security", null); + app.addHook("onRequest", async (request, reply) => { + const remoteAddress = request.socket?.remoteAddress ?? request.raw?.socket?.remoteAddress; + const security = requestSecurity({ headers: request.headers, remoteAddress, trustedHops: o.trustedHops }); + request.security = security; + const pre = p.isPreflight(request.method, request.headers); + for (const [k, v] of Object.entries(p.headers(security.origin, pre))) reply.header(k, v); + if (pre) return reply.code(204).send(); + return undefined; + }); + done(); + }; + // what fastify-plugin sets, so hooks and the decorator apply outside the plugin's own scope + (plugin as unknown as Record)[Symbol.for("skip-override")] = true; + (plugin as unknown as Record)[Symbol.for("fastify.display-name")] = "mgpl-security"; + return plugin; +} diff --git a/src/server/index.ts b/src/server/index.ts index 802f126..ba16b37 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -16,3 +16,4 @@ export { clientIp, normalizeIp, type ClientIpOptions } from "./ip"; export { API_CSP, corsPolicy, securityHeaders, type CorsOptions, type SecurityHeaderOptions } from "./headers"; export { maskEmail, maskPhone, REDACTED, redact, SENSITIVE_KEYS, type RedactOptions } from "./redact"; export * from "../index"; +export { expressSecurity, fastifySecurity, fetchSecurity, requestSecurity, type RequestSecurity, type SecurityOptions } from "./adapters"; diff --git a/tsconfig.browser.json b/tsconfig.browser.json index 6b06b88..d5b84c8 100644 --- a/tsconfig.browser.json +++ b/tsconfig.browser.json @@ -2,5 +2,5 @@ // The shared and browser parts must not use Node.js APIs: type-checked here without @types/node. "extends": "./tsconfig.json", "compilerOptions": { "types": [] }, - "include": ["src/index.ts", "src/shared", "src/browser"] + "include": ["src/index.ts", "src/react.ts", "src/shared", "src/browser"] } diff --git a/tsup.config.ts b/tsup.config.ts index f81eb55..e6e5480 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,11 +1,23 @@ import { defineConfig } from "tsup"; -export default defineConfig({ - entry: { index: "src/index.ts", browser: "src/browser/index.ts", server: "src/server/index.ts" }, - format: ["esm"], - dts: true, - target: "es2022", - clean: true, - splitting: true, - external: [/^node:/], -}); +// two builds run in parallel, so neither cleans dist/ (the build script does it first) + +export default defineConfig([ + { + entry: { index: "src/index.ts", browser: "src/browser/index.ts", server: "src/server/index.ts" }, + format: ["esm"], + dts: true, + target: "es2022", + splitting: true, + external: [/^node:/], + }, + // the optional React hook, marked as a client module for Next.js + { + entry: { react: "src/react.ts" }, + format: ["esm"], + dts: true, + target: "es2022", + external: ["react"], + banner: { js: '"use client";' }, + }, +]);