diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..e18a3fa
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,2 @@
+# Every change to this repo needs a review from a code owner (see CONTRIBUTING in mgpl-diagnostics/.github).
+* @BackStacked
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..fdd195c
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,21 @@
+version: 2
+updates:
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ groups:
+ minor-and-patch:
+ update-types: [minor, patch]
+ ignore:
+ # our own packages are released together and bumped by hand
+ - dependency-name: "@mgpl-diagnostics/*"
+ - dependency-name: "typescript"
+ update-types: ["version-update:semver-major"]
+ - dependency-name: "@types/node"
+ update-types: ["version-update:semver-major"]
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: monthly
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e76750c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ # The job id "ci" is the required status check on main (see mgpl-diagnostics/.github scripts/protect-repos.mjs).
+ ci:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ - run: npm install --no-package-lock
+ - run: npm run typecheck
+ - run: npm test
+ - run: npm run build
+ - name: Main and /browser bundles load no Node.js modules
+ run: node scripts/check-dist.mjs
+ - name: llms-full.txt is up to date (run `npm run llms` and commit if this fails)
+ run: npm run llms && git diff --exit-code -- llms-full.txt
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..68af1d8
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,34 @@
+name: Publish to GitHub Packages
+
+on:
+ push:
+ tags: ["v*"]
+ # the owner can also publish main's package.json version by hand (Actions → Run workflow)
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ publish:
+ # tags are admin-only; a manual run is limited to main and to the code owner
+ if: github.event_name == 'push' || (github.ref == 'refs/heads/main' && github.actor == 'BackStacked')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ registry-url: https://npm.pkg.github.com
+ scope: "@mgpl-diagnostics"
+ - name: Tag must match package.json version
+ if: github.event_name == 'push'
+ run: test "v$(node -p "require('./package.json').version")" = "${GITHUB_REF_NAME}"
+ # installs dependencies (incl. @mgpl-diagnostics/* peers from GitHub Packages) and builds dist/ via "prepare"
+ - run: npm install --no-package-lock
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - run: npm publish
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..8bb7be9
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,54 @@
+# AGENTS.md — @mgpl-diagnostics/security-kit
+
+Security primitives used on **both sides**: the frontend (`/browser`, through `@mgpl-diagnostics/client`
+and apps) and the backend (`/server`, through `mgpl-diagnostics/api` and auth flows). **A library: no
+server, no GraphQL, no UI, zero runtime dependencies.**
+
+## Map
+
+```
+src/index.ts shared entry (browser, Node.js, workers, React Native)
+src/shared/types.ts DeviceSignals, DeviceInfo
+src/shared/constants.ts SIGNALS_HEADER ("x-mgpl-signals"), SIGNAL_LIMITS, ID_PATTERN, FINGERPRINT_PATTERN
+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)
+src/server/ "/server": signals.ts (readSignals, bindSignals, checkSignals, decide), ip.ts (clientIp),
+ headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone)
+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
+```
+
+## Rules
+
+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.
+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).
+5. **Everything read from a request is untrusted.** `decodeSignals` / `readSignals` never throw, cap
+ sizes, and return `null` for anything malformed. Keep it that way and add a test for each new field.
+6. The signal format is a contract between client and api: `SIGNALS_HEADER`, field names and `v: 1`.
+ Add optional fields; a breaking change needs `v: 2` and both versions accepted during the rollout.
+7. Verdict names (`same`, `changed`, `other-device`, `missing`, `unbound`) are public: add, never rename.
+8. Token logic (passwords, sessions, JWT) stays in **auth-kit**; permissions stay in auth-kit.
+9. Never commit secrets, `.env` files or real patient data (tests use fictional values).
+
+## Commands
+
+```bash
+npm run typecheck && npm test && npm run build && node scripts/check-dist.mjs
+npm run llms # regenerate llms-full.txt (CI fails when it is stale)
+```
+
+## Repository rules
+
+`main` takes pull requests only: squash merge, 1 code-owner review (`@BackStacked`), the `ci` check
+required. Rules come from `mgpl-diagnostics/.github` (`scripts/protect-repos.mjs`).
+
+## Licence
+
+All rights reserved (see LICENSE).
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e51d35e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,143 @@
+
+
+# security-kit
+
+**Security primitives for both sides of MGPL Diagnostics.**
+Device signals collected in any frontend and checked on the backend, plus the server helpers every
+endpoint needs. Zero dependencies.
+
+  
+
+
+
+```bash
+# .npmrc: @mgpl-diagnostics:registry=https://npm.pkg.github.com (a token with read:packages)
+npm install @mgpl-diagnostics/security-kit
+```
+
+## Entries
+
+| Import | Runs in | What it has |
+| --- | --- | --- |
+| `@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` |
+
+Both `/browser` and `/server` re-export the shared entry.
+
+## Device signals
+
+```mermaid
+sequenceDiagram
+ participant B as Browser (security-kit/browser)
+ participant A as api (security-kit/server + auth-kit)
+ B->>B: collect(): deviceId, tabId, fingerprint, device info
+ B->>A: login / refresh with header x-mgpl-signals
+ A->>A: readSignals(headers) (validated, size-capped)
+ Note over A: login: bindSignals(signals) is stored on the session
+ Note over A: refresh: checkSignals(binding, signals) then decide()
+ A-->>B: same: OK · changed: OK + audit · other-device: session ended
+```
+
+### What is collected
+
+| Field | Source | Kept where |
+| --- | --- | --- |
+| `deviceId` | 128-bit random id, created once | browser `localStorage` (`mgpl.deviceId`) |
+| `tabId` | 128-bit random id per tab | browser `sessionStorage` (`mgpl.tabId`) |
+| `fingerprint` | SHA-256 (first 32 hex) of: OS, browser family, mobile, `navigator.platform`, language, time zone, screen size, colour depth, CPU cores, device memory, touch points | sent only |
+| `device` | OS, browser and major version, mobile, language, time zone, screen size | sent only |
+| `app` | the app's version, if the app sets one | sent only |
+
+No canvas, audio, WebGL or font probing, no IP lookup, nothing sent to third parties. The browser version
+is left out of the fingerprint so updates don't change it. On the server, `bindSignals` stores
+**SHA-256 hashes of the ids**, never the ids themselves.
+
+### Browser
+
+```ts
+import { createSignalCollector } from "@mgpl-diagnostics/security-kit/browser";
+
+export const signals = createSignalCollector({ app: "lab-panel@1.4.0" });
+
+// any fetch wrapper, GraphQL client or query library
+const res = await fetch("/graphql", {
+ method: "POST",
+ headers: { "content-type": "application/json", ...(await signals.headers()) },
+ body: JSON.stringify({ query, variables }),
+});
+
+await signals.reset(); // "forget this device", e.g. after signing out on a shared computer
+```
+
+React Native or other runtimes pass their own storage and environment:
+
+```ts
+createSignalCollector({
+ storage: { get: (k) => AsyncStorage.getItem(k), set: (k, v) => AsyncStorage.setItem(k, v), remove: (k) => AsyncStorage.removeItem(k) },
+ tabStorage: memoryStorage(),
+ environment: () => ({ navigator: { userAgent: "MGPL-App/2.0 (Android 14)", language: "en-IN" }, screen: { width: 1080, height: 2400 } }),
+});
+```
+
+### Server
+
+```ts
+import { bindSignals, checkSignals, decide, readSignals } from "@mgpl-diagnostics/security-kit/server";
+
+// login: store the binding with the session
+const signals = readSignals(request.headers); // null when missing or invalid
+session.signals = signals ? bindSignals(signals) : null;
+
+// refresh: compare
+const check = checkSignals(session.signals, readSignals(request.headers));
+const { allow, log } = decide(check, { rejectOtherDevice: true, requireSignals: false });
+if (log) audit({ action: "auth.signals_" + check.verdict, meta: { changes: check.changes } });
+if (!allow) endSession();
+```
+
+| Verdict | Meaning | Default decision |
+| --- | --- | --- |
+| `same` | same device id and fingerprint (another tab or a browser update is still `same`) | allow |
+| `changed` | same device id; fingerprint, OS, browser, form factor, language, time zone or screen changed | allow, log |
+| `other-device` | a different device id: the token is used from another browser | **reject**, log |
+| `missing` | the session is bound but the request sent no valid signals | allow, log (`requireSignals: true` rejects) |
+| `unbound` | the session has no binding (older session or non-browser client) | allow |
+
+Signals are a second line of defence, not proof of identity: a determined attacker who steals a token can
+also copy the ids. They make silent token reuse from another browser visible and stoppable.
+
+## Server helpers
+
+```ts
+import { clientIp, corsPolicy, redact, securityHeaders } from "@mgpl-diagnostics/security-kit/server";
+
+// behind one load balancer; X-Forwarded-For is ignored when trustedHops is 0 (the default)
+const ip = clientIp({ remoteAddress: req.socket.remoteAddress, headers: req.headers, trustedHops: 1 });
+
+const cors = corsPolicy({ origins: ["https://lab.example.in", "https://*.mgpl.example"] });
+reply.headers({ ...securityHeaders(), ...cors.headers(req.headers.origin, req.method === "OPTIONS") });
+
+logger.info(redact({ body, headers })); // passwords, tokens, cookies, keys → "[redacted]"
+```
+
+- `securityHeaders()`: HSTS, `nosniff`, `DENY` framing, `no-referrer`, a locked-down CSP for a JSON API,
+ `Permissions-Policy`, `no-store`. Each can be changed or turned off (GraphiQL needs its own CSP).
+- `corsPolicy()`: exact origins and `https://*.domain` patterns (never the bare domain, never another
+ scheme), `"*"` only without credentials; preflights always allow `x-mgpl-signals`.
+- `redact()`: deep copy with secrets hidden (`password`, `*Token`, `*Secret`, `authorization`, `cookie`,
+ API keys, OTP…), bearer strings, `Headers`, `Map`, errors and cycles; add your own keys.
+ `maskEmail` / `maskPhone` for patient contact details in logs.
+
+## Develop
+
+```bash
+npm run typecheck # also checks that the shared and browser parts compile without Node.js types
+npm test # 32 checks
+npm run build && node scripts/check-dist.mjs
+npm run llms # regenerate llms-full.txt (CI fails when it is stale)
+```
+
+## Licence
+
+All rights reserved. See [LICENSE](LICENSE).
diff --git a/llms-full.txt b/llms-full.txt
new file mode 100644
index 0000000..62e5877
--- /dev/null
+++ b/llms-full.txt
@@ -0,0 +1,1748 @@
+# @mgpl-diagnostics/security-kit — full documentation and source
+
+> Generated by scripts/llms-full.mjs. Do not edit by hand. 21 files.
+
+## Contents
+
+- README.md
+- AGENTS.md
+- llms.txt
+- package.json
+- scripts/check-dist.mjs
+- scripts/llms-full.mjs
+- scripts/test.ts
+- src/browser/collect.ts
+- src/browser/device.ts
+- src/browser/index.ts
+- src/browser/storage.ts
+- src/index.ts
+- src/server/headers.ts
+- src/server/index.ts
+- src/server/ip.ts
+- src/server/redact.ts
+- src/server/signals.ts
+- src/shared/constants.ts
+- src/shared/crypto.ts
+- src/shared/signals.ts
+- src/shared/types.ts
+
+
+---
+
+## File: README.md
+
+
+
+# security-kit
+
+**Security primitives for both sides of MGPL Diagnostics.**
+Device signals collected in any frontend and checked on the backend, plus the server helpers every
+endpoint needs. Zero dependencies.
+
+  
+
+
+
+```bash
+# .npmrc: @mgpl-diagnostics:registry=https://npm.pkg.github.com (a token with read:packages)
+npm install @mgpl-diagnostics/security-kit
+```
+
+## Entries
+
+| Import | Runs in | What it has |
+| --- | --- | --- |
+| `@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` |
+
+Both `/browser` and `/server` re-export the shared entry.
+
+## Device signals
+
+```mermaid
+sequenceDiagram
+ participant B as Browser (security-kit/browser)
+ participant A as api (security-kit/server + auth-kit)
+ B->>B: collect(): deviceId, tabId, fingerprint, device info
+ B->>A: login / refresh with header x-mgpl-signals
+ A->>A: readSignals(headers) (validated, size-capped)
+ Note over A: login: bindSignals(signals) is stored on the session
+ Note over A: refresh: checkSignals(binding, signals) then decide()
+ A-->>B: same: OK · changed: OK + audit · other-device: session ended
+```
+
+### What is collected
+
+| Field | Source | Kept where |
+| --- | --- | --- |
+| `deviceId` | 128-bit random id, created once | browser `localStorage` (`mgpl.deviceId`) |
+| `tabId` | 128-bit random id per tab | browser `sessionStorage` (`mgpl.tabId`) |
+| `fingerprint` | SHA-256 (first 32 hex) of: OS, browser family, mobile, `navigator.platform`, language, time zone, screen size, colour depth, CPU cores, device memory, touch points | sent only |
+| `device` | OS, browser and major version, mobile, language, time zone, screen size | sent only |
+| `app` | the app's version, if the app sets one | sent only |
+
+No canvas, audio, WebGL or font probing, no IP lookup, nothing sent to third parties. The browser version
+is left out of the fingerprint so updates don't change it. On the server, `bindSignals` stores
+**SHA-256 hashes of the ids**, never the ids themselves.
+
+### Browser
+
+```ts
+import { createSignalCollector } from "@mgpl-diagnostics/security-kit/browser";
+
+export const signals = createSignalCollector({ app: "lab-panel@1.4.0" });
+
+// any fetch wrapper, GraphQL client or query library
+const res = await fetch("/graphql", {
+ method: "POST",
+ headers: { "content-type": "application/json", ...(await signals.headers()) },
+ body: JSON.stringify({ query, variables }),
+});
+
+await signals.reset(); // "forget this device", e.g. after signing out on a shared computer
+```
+
+React Native or other runtimes pass their own storage and environment:
+
+```ts
+createSignalCollector({
+ storage: { get: (k) => AsyncStorage.getItem(k), set: (k, v) => AsyncStorage.setItem(k, v), remove: (k) => AsyncStorage.removeItem(k) },
+ tabStorage: memoryStorage(),
+ environment: () => ({ navigator: { userAgent: "MGPL-App/2.0 (Android 14)", language: "en-IN" }, screen: { width: 1080, height: 2400 } }),
+});
+```
+
+### Server
+
+```ts
+import { bindSignals, checkSignals, decide, readSignals } from "@mgpl-diagnostics/security-kit/server";
+
+// login: store the binding with the session
+const signals = readSignals(request.headers); // null when missing or invalid
+session.signals = signals ? bindSignals(signals) : null;
+
+// refresh: compare
+const check = checkSignals(session.signals, readSignals(request.headers));
+const { allow, log } = decide(check, { rejectOtherDevice: true, requireSignals: false });
+if (log) audit({ action: "auth.signals_" + check.verdict, meta: { changes: check.changes } });
+if (!allow) endSession();
+```
+
+| Verdict | Meaning | Default decision |
+| --- | --- | --- |
+| `same` | same device id and fingerprint (another tab or a browser update is still `same`) | allow |
+| `changed` | same device id; fingerprint, OS, browser, form factor, language, time zone or screen changed | allow, log |
+| `other-device` | a different device id: the token is used from another browser | **reject**, log |
+| `missing` | the session is bound but the request sent no valid signals | allow, log (`requireSignals: true` rejects) |
+| `unbound` | the session has no binding (older session or non-browser client) | allow |
+
+Signals are a second line of defence, not proof of identity: a determined attacker who steals a token can
+also copy the ids. They make silent token reuse from another browser visible and stoppable.
+
+## Server helpers
+
+```ts
+import { clientIp, corsPolicy, redact, securityHeaders } from "@mgpl-diagnostics/security-kit/server";
+
+// behind one load balancer; X-Forwarded-For is ignored when trustedHops is 0 (the default)
+const ip = clientIp({ remoteAddress: req.socket.remoteAddress, headers: req.headers, trustedHops: 1 });
+
+const cors = corsPolicy({ origins: ["https://lab.example.in", "https://*.mgpl.example"] });
+reply.headers({ ...securityHeaders(), ...cors.headers(req.headers.origin, req.method === "OPTIONS") });
+
+logger.info(redact({ body, headers })); // passwords, tokens, cookies, keys → "[redacted]"
+```
+
+- `securityHeaders()`: HSTS, `nosniff`, `DENY` framing, `no-referrer`, a locked-down CSP for a JSON API,
+ `Permissions-Policy`, `no-store`. Each can be changed or turned off (GraphiQL needs its own CSP).
+- `corsPolicy()`: exact origins and `https://*.domain` patterns (never the bare domain, never another
+ scheme), `"*"` only without credentials; preflights always allow `x-mgpl-signals`.
+- `redact()`: deep copy with secrets hidden (`password`, `*Token`, `*Secret`, `authorization`, `cookie`,
+ API keys, OTP…), bearer strings, `Headers`, `Map`, errors and cycles; add your own keys.
+ `maskEmail` / `maskPhone` for patient contact details in logs.
+
+## Develop
+
+```bash
+npm run typecheck # also checks that the shared and browser parts compile without Node.js types
+npm test # 32 checks
+npm run build && node scripts/check-dist.mjs
+npm run llms # regenerate llms-full.txt (CI fails when it is stale)
+```
+
+## Licence
+
+All rights reserved. See [LICENSE](LICENSE).
+
+
+---
+
+## File: AGENTS.md
+
+# AGENTS.md — @mgpl-diagnostics/security-kit
+
+Security primitives used on **both sides**: the frontend (`/browser`, through `@mgpl-diagnostics/client`
+and apps) and the backend (`/server`, through `mgpl-diagnostics/api` and auth flows). **A library: no
+server, no GraphQL, no UI, zero runtime dependencies.**
+
+## Map
+
+```
+src/index.ts shared entry (browser, Node.js, workers, React Native)
+src/shared/types.ts DeviceSignals, DeviceInfo
+src/shared/constants.ts SIGNALS_HEADER ("x-mgpl-signals"), SIGNAL_LIMITS, ID_PATTERN, FINGERPRINT_PATTERN
+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)
+src/server/ "/server": signals.ts (readSignals, bindSignals, checkSignals, decide), ip.ts (clientIp),
+ headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone)
+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
+```
+
+## Rules
+
+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.
+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).
+5. **Everything read from a request is untrusted.** `decodeSignals` / `readSignals` never throw, cap
+ sizes, and return `null` for anything malformed. Keep it that way and add a test for each new field.
+6. The signal format is a contract between client and api: `SIGNALS_HEADER`, field names and `v: 1`.
+ Add optional fields; a breaking change needs `v: 2` and both versions accepted during the rollout.
+7. Verdict names (`same`, `changed`, `other-device`, `missing`, `unbound`) are public: add, never rename.
+8. Token logic (passwords, sessions, JWT) stays in **auth-kit**; permissions stay in auth-kit.
+9. Never commit secrets, `.env` files or real patient data (tests use fictional values).
+
+## Commands
+
+```bash
+npm run typecheck && npm test && npm run build && node scripts/check-dist.mjs
+npm run llms # regenerate llms-full.txt (CI fails when it is stale)
+```
+
+## Repository rules
+
+`main` takes pull requests only: squash merge, 1 code-owner review (`@BackStacked`), the `ci` check
+required. Rules come from `mgpl-diagnostics/.github` (`scripts/protect-repos.mjs`).
+
+## Licence
+
+All rights reserved (see LICENSE).
+
+
+---
+
+## File: llms.txt
+
+# @mgpl-diagnostics/security-kit
+
+> 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:
+- 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.
+- Token and session logic stays in auth-kit; security-kit only supplies signals and helpers.
+
+## Docs
+
+- [README](README.md): entries, what is collected, use, verdicts, server helpers
+- [AGENTS.md](AGENTS.md): map and rules
+- [llms-full.txt](llms-full.txt): every doc and source file in one document
+
+## Source
+
+- [src/shared/signals.ts](src/shared/signals.ts): encode, decode and validate signals
+- [src/browser/collect.ts](src/browser/collect.ts): createSignalCollector
+- [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
+- [src/server/headers.ts](src/server/headers.ts): securityHeaders, corsPolicy
+- [src/server/redact.ts](src/server/redact.ts): redact, maskEmail, maskPhone
+- [scripts/test.ts](scripts/test.ts): all checks
+
+## Optional
+
+- [auth-kit](https://github.com/mgpl-diagnostics/auth-kit)
+- [client](https://github.com/mgpl-diagnostics/client)
+- [api](https://github.com/mgpl-diagnostics/api)
+
+
+---
+
+## File: package.json
+
+```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.",
+ "type": "module",
+ "license": "SEE LICENSE IN LICENSE",
+ "repository": "github:mgpl-diagnostics/security-kit",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./browser": {
+ "types": "./dist/browser.d.ts",
+ "import": "./dist/browser.js"
+ },
+ "./server": {
+ "types": "./dist/server.d.ts",
+ "import": "./dist/server.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "AGENTS.md",
+ "llms.txt",
+ "LICENSE",
+ "llms-full.txt"
+ ],
+ "publishConfig": {
+ "registry": "https://npm.pkg.github.com"
+ },
+ "scripts": {
+ "build": "tsup",
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.browser.json",
+ "test": "tsx scripts/test.ts",
+ "prepare": "tsup",
+ "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/security-kit README.md AGENTS.md llms.txt package.json src scripts"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.5",
+ "tsup": "^8.5.0",
+ "tsx": "^4.23.15",
+ "typescript": "^5.9.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
+```
+
+
+---
+
+## File: scripts/check-dist.mjs
+
+```js
+// After `npm run build`: the main and /browser bundles (and every chunk they import) must not load
+// Node.js modules, so they run in browsers, workers and React Native. /server may.
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+const seen = new Set();
+const bad = [];
+function walk(file) {
+ if (seen.has(file)) return;
+ seen.add(file);
+ const src = readFileSync(file, "utf8");
+ for (const m of src.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)) {
+ const spec = m[1];
+ if (spec.startsWith("node:") || /^(crypto|net|fs|path|buffer|os)$/.test(spec)) bad.push(`${file} imports ${spec}`);
+ else if (spec.startsWith(".")) walk(join(dirname(file), spec));
+ }
+ if (/\bBuffer\./.test(src)) bad.push(`${file} uses Buffer`);
+}
+walk("dist/index.js");
+walk("dist/browser.js");
+if (bad.length) {
+ console.error(bad.join("\n"));
+ process.exit(1);
+}
+console.log(`dist: ${seen.size} browser-safe files`);
+```
+
+
+---
+
+## File: scripts/llms-full.mjs
+
+````js
+// Builds llms-full.txt: every doc and source file of the repo in one plain-text document for AI tools.
+// node scripts/llms-full.mjs "" [file-or-folder …]
+// Folders are walked recursively. Output order: README, AGENTS, docs, then the rest alphabetically.
+// Skipped: build output, dependencies, lockfiles, generated files, binaries. Files over 150 KB are
+// listed (path + size) instead of inlined. CI checks the file is up to date (`npm run llms` + git diff).
+import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
+import { extname, join, relative } from "node:path";
+
+const [title, ...inputs] = process.argv.slice(2);
+if (!title || !inputs.length) {
+ console.error('usage: node scripts/llms-full.mjs "" …');
+ process.exit(1);
+}
+
+const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".md", ".mdx", ".json", ".css", ".yml", ".yaml", ".txt", ".graphql", ".gql", ".sql", ".html"]);
+const SKIP_DIRS = new Set(["node_modules", "dist", ".next", ".git", ".turbo", "coverage", "examples", ".pg-test"]);
+const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|llms-full\.txt|[^/]*\.generated\.[^/]+|[^/]*\.log)$/;
+const MAX = 150 * 1024;
+const LANG = { ".ts": "ts", ".tsx": "tsx", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".json": "json", ".css": "css", ".yml": "yaml", ".yaml": "yaml", ".graphql": "graphql", ".gql": "graphql", ".sql": "sql", ".html": "html" };
+
+const posix = (p) => p.split("\\").join("/");
+const files = new Set();
+function walk(p) {
+ const st = statSync(p);
+ if (st.isDirectory()) {
+ for (const name of readdirSync(p)) if (!SKIP_DIRS.has(name)) walk(join(p, name));
+ } else if (TEXT.has(extname(p).toLowerCase()) && !SKIP_FILES.test(posix(p))) {
+ files.add(posix(relative(".", p)));
+ }
+}
+for (const input of inputs) walk(input);
+
+const rank = (f) => (/^README\.md$/i.test(f) ? 0 : /^AGENTS\.md$/i.test(f) ? 1 : /^docs\//.test(f) ? 2 : 3);
+const ordered = [...files].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
+
+const parts = [`# ${title} — full documentation and source\n\n> Generated by scripts/llms-full.mjs. Do not edit by hand. ${ordered.length} files.\n\n## Contents\n\n${ordered.map((f) => `- ${f}`).join("\n")}\n`];
+const omitted = [];
+for (const f of ordered) {
+ // LF-normalised text, so Windows and Linux checkouts produce byte-identical output
+ const body = readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim();
+ const size = Buffer.byteLength(body);
+ if (size > MAX) {
+ omitted.push(`${f} (${(size / 1024).toFixed(0)} KB)`);
+ parts.push(`\n\n---\n\n## File: ${f}\n\n_Omitted: ${(size / 1024).toFixed(0)} KB. Read the file directly._\n`);
+ continue;
+ }
+ const lang = LANG[extname(f).toLowerCase()];
+ const fence = body.includes("```") ? "````" : "```";
+ parts.push(`\n\n---\n\n## File: ${f}\n\n${lang ? `${fence}${lang}\n${body}\n${fence}` : body}\n`);
+}
+const out = parts.join("");
+writeFileSync("llms-full.txt", out);
+console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFixed(0)} KB${omitted.length ? `; omitted (too large): ${omitted.join(", ")}` : ""}`);
+````
+
+
+---
+
+## File: scripts/test.ts
+
+```ts
+// npm test: every part of security-kit, run on Node.js. The browser part runs against simulated
+// environments and storages (it only needs Web Crypto, which Node.js >= 20 has).
+import {
+ canonicalJson,
+ constantTimeEqual,
+ decodeSignals,
+ encodeSignals,
+ fromBase64Url,
+ ID_PATTERN,
+ parseSignals,
+ randomId,
+ sha256Hex,
+ SIGNALS_HEADER,
+ toBase64Url,
+ type DeviceSignals,
+} from "../src/index";
+import { browserOf, createSignalCollector, deviceInfo, memoryStorage, webStorage, type SignalEnvironment, type SignalStorage } from "../src/browser/index";
+import {
+ bindSignals,
+ checkSignals,
+ clientIp,
+ corsPolicy,
+ decide,
+ maskEmail,
+ maskPhone,
+ normalizeIp,
+ readSignals,
+ redact,
+ securityHeaders,
+} from "../src/server/index";
+
+let passed = 0;
+const failures: string[] = [];
+async function check(name: string, fn: () => unknown | Promise) {
+ try {
+ const r = await fn();
+ if (r === false) throw new Error("returned false");
+ passed++;
+ } catch (e) {
+ failures.push(`${name}: ${(e as Error).message}`);
+ }
+}
+function eq(a: unknown, b: unknown) {
+ const x = JSON.stringify(a), y = JSON.stringify(b);
+ if (x !== y) throw new Error(`expected ${y}, got ${x}`);
+}
+function ok(v: unknown, msg = "expected truthy") {
+ if (!v) throw new Error(msg);
+}
+
+// ---------- shared ----------
+
+await check("base64url round-trips bytes and UTF-8", () => {
+ eq(toBase64Url("hello"), "aGVsbG8");
+ const s = "नमस्ते ✓ <>";
+ eq(new TextDecoder().decode(fromBase64Url(toBase64Url(s))!), s);
+ const bytes = new Uint8Array([0, 255, 62, 63, 251]);
+ eq([...fromBase64Url(toBase64Url(bytes))!], [...bytes]);
+ ok(!toBase64Url(bytes).includes("+") && !toBase64Url(bytes).includes("/") && !toBase64Url(bytes).includes("="));
+});
+await check("fromBase64Url rejects invalid input", () => {
+ eq(fromBase64Url("a+b/"), null);
+ eq(fromBase64Url("abcde"), null);
+ eq(fromBase64Url("ab c"), null);
+});
+await check("randomId: 22 base64url chars, unique, fits ID_PATTERN", () => {
+ const ids = new Set(Array.from({ length: 500 }, () => randomId()));
+ eq(ids.size, 500);
+ for (const id of ids) ok(id.length === 22 && ID_PATTERN.test(id), id);
+});
+await check("sha256Hex matches the FIPS 180-2 vector", async () => {
+ eq(await sha256Hex("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
+});
+await check("constantTimeEqual", () => {
+ ok(constantTimeEqual("abc", "abc"));
+ ok(!constantTimeEqual("abc", "abd"));
+ ok(!constantTimeEqual("abc", "abcd"));
+});
+await check("canonicalJson sorts keys at every level and drops undefined", () => {
+ eq(canonicalJson({ b: 1, a: { d: [2, { z: 1, y: 2 }], c: undefined } }), '{"a":{"d":[2,{"y":2,"z":1}]},"b":1}');
+});
+
+// ---------- browser: device info ----------
+
+const UA = {
+ chromeWin: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+ edgeWin: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.70",
+ safariIphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
+ safariMac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
+ chromeIos: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1",
+ samsung: "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36",
+ firefoxLinux: "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0",
+ opera: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0.0",
+ chromebook: "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+ androidTablet: "Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+};
+
+const env = (ua: string, extra: Partial = {}, screen = { width: 1920, height: 1080, colorDepth: 24 }): SignalEnvironment => ({
+ navigator: { userAgent: ua, language: "en-IN", platform: "Win32", hardwareConcurrency: 8, deviceMemory: 8, maxTouchPoints: 0, ...extra },
+ screen,
+ timezone: "Asia/Kolkata",
+});
+
+await check("browser and version from user agents", () => {
+ eq(browserOf(UA.chromeWin), { browser: "Chrome", browserVersion: "131" });
+ eq(browserOf(UA.edgeWin), { browser: "Edge", browserVersion: "131" });
+ eq(browserOf(UA.safariIphone), { browser: "Safari", browserVersion: "17" });
+ eq(browserOf(UA.safariMac), { browser: "Safari", browserVersion: "17" });
+ eq(browserOf(UA.chromeIos), { browser: "Chrome", browserVersion: "131" });
+ eq(browserOf(UA.samsung), { browser: "Samsung Internet", browserVersion: "26" });
+ eq(browserOf(UA.firefoxLinux), { browser: "Firefox", browserVersion: "133" });
+ eq(browserOf(UA.opera), { browser: "Opera", browserVersion: "115" });
+ eq(browserOf("curl/8.0"), { browser: "Other", browserVersion: "" });
+});
+await check("OS and form factor", () => {
+ eq([deviceInfo(env(UA.chromeWin)).os, deviceInfo(env(UA.chromeWin)).mobile], ["Windows", false]);
+ eq([deviceInfo(env(UA.safariIphone)).os, deviceInfo(env(UA.safariIphone)).mobile], ["iOS", true]);
+ eq(deviceInfo(env(UA.safariMac)).os, "macOS");
+ eq(deviceInfo(env(UA.safariMac, { maxTouchPoints: 5 })).os, "iOS"); // iPadOS reports a Mac UA
+ eq([deviceInfo(env(UA.samsung)).os, deviceInfo(env(UA.samsung)).mobile], ["Android", true]);
+ eq([deviceInfo(env(UA.androidTablet)).os, deviceInfo(env(UA.androidTablet)).mobile], ["Android", false]);
+ eq(deviceInfo(env(UA.firefoxLinux)).os, "Linux");
+ eq(deviceInfo(env(UA.chromebook)).os, "ChromeOS");
+ eq(deviceInfo(env(UA.chromeWin, { userAgentData: { mobile: true } })).mobile, true);
+});
+await check("device info: language, time zone, screen (rotation-proof), empty environment", () => {
+ const d = deviceInfo(env(UA.chromeWin));
+ eq([d.language, d.timezone, d.screen], ["en-IN", "Asia/Kolkata", "1920x1080"]);
+ eq(deviceInfo(env(UA.safariIphone, {}, { width: 390, height: 844, colorDepth: 24 })).screen, "844x390");
+ eq(deviceInfo(env(UA.safariIphone, {}, { width: 844, height: 390, colorDepth: 24 })).screen, "844x390");
+ eq(deviceInfo({}), { os: "Other", browser: "Other", browserVersion: "", mobile: false, language: "", timezone: "", screen: "" });
+});
+
+// ---------- browser: collector ----------
+
+function collector(e: SignalEnvironment, storage = memoryStorage(), tabStorage = memoryStorage(), app?: string) {
+ return createSignalCollector({ storage, tabStorage, environment: () => e, app });
+}
+
+await check("collector: valid signals; ids persist across collectors sharing storage", async () => {
+ const storage = memoryStorage();
+ const tab = memoryStorage();
+ const a = await collector(env(UA.chromeWin), storage, tab).collect();
+ const b = await collector(env(UA.chromeWin), storage, tab).collect();
+ ok(parseSignals(a), "parseSignals rejected collected signals");
+ eq(a, b);
+ const otherTab = await collector(env(UA.chromeWin), storage, memoryStorage()).collect();
+ eq(otherTab.deviceId, a.deviceId);
+ ok(otherTab.tabId !== a.tabId, "new tab gets a new tab id");
+ ok(/^[0-9a-f]{32}$/.test(a.fingerprint));
+});
+await check("collector: fingerprint ignores browser version, changes with setup", async () => {
+ const base = await collector(env(UA.chromeWin)).collect();
+ const updated = await collector(env(UA.chromeWin.replace("Chrome/131", "Chrome/132"))).collect();
+ eq(updated.fingerprint, base.fingerprint);
+ const newScreen = await collector(env(UA.chromeWin, {}, { width: 2560, height: 1440, colorDepth: 24 })).collect();
+ ok(newScreen.fingerprint !== base.fingerprint);
+ const otherBrowser = await collector(env(UA.firefoxLinux)).collect();
+ ok(otherBrowser.fingerprint !== base.fingerprint);
+});
+await check("collector: caches, refresh re-reads, reset forgets ids", async () => {
+ let lang = "en-IN";
+ const storage = memoryStorage(), tab = memoryStorage();
+ const c = createSignalCollector({ storage, tabStorage: tab, environment: () => env(UA.chromeWin, { language: lang }) });
+ const first = await c.collect();
+ lang = "hi-IN";
+ eq((await c.collect()).device.language, "en-IN");
+ const r = await c.refresh();
+ eq(r.device.language, "hi-IN");
+ eq(r.deviceId, first.deviceId);
+ await c.reset();
+ const after = await c.collect();
+ ok(after.deviceId !== first.deviceId && after.tabId !== first.tabId);
+});
+await check("collector: headers round-trip through decodeSignals; app is sent", async () => {
+ const c = collector(env(UA.safariIphone), memoryStorage(), memoryStorage(), "lab-panel@1.4.0");
+ const h = await c.headers();
+ eq(Object.keys(h), [SIGNALS_HEADER]);
+ eq(decodeSignals(h[SIGNALS_HEADER]), await c.collect());
+ eq((await c.collect()).app, "lab-panel@1.4.0");
+ ok((await c.headerValue()).length < 1024, "header stays small");
+});
+await check("collector: replaces a tampered stored id", async () => {
+ const storage = memoryStorage();
+ await storage.set("mgpl.deviceId", "