## @s4e/jsentinel

Lightweight browser sentinel for script integrity and clipboard tamper detection. Zero setup at runtime, event-driven, framework-agnostic.

- Script integrity: hashes every `<script>` on the page (SHA‑256) and compares against a manifest you ship.
- Clipboard tamper: after a copy event, warns if pasted clipboard text differs from the text the user selected.

### Install

```bash
npm install @s4e/jsentinel
# or
yarn add @s4e/jsentinel
# or
pnpm add @s4e/jsentinel
# or
bun add @s4e/jsentinel
```

### Quick start

The fastest way is the auto entry, which safely no-ops on the server and only runs in the browser:

```ts
import "@s4e/jsentinel/auto";
```

Manual init (for custom options):

```ts
import {
  initJSentinel,
  attachConsoleLogger,
  onJSentinel,
} from "@s4e/jsentinel";

await initJSentinel({
  manifestPath: "/manifest.json",
  watchClipboard: true,
  verboseChecks: false,
});

// Optional: log to console
const stopLogging = attachConsoleLogger();

// Example: react to mismatches
const remove = onJSentinel("script.mismatch", (data) => {
  console.error("Script mismatch:", data);
});
```

### Framework recipes

All options below are safe for SSR. The `/auto` module checks `window`/`document` and only runs on the client.

#### Next.js (Pages Router – `_app.tsx`)

1. Direct import (simple):

```tsx
// pages/_app.tsx
import type { AppProps } from "next/app";
import "@s4e/jsentinel/auto";

export default function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}
```

2. Client-only dynamic import (stricter SSR separation):

```tsx
// pages/_app.tsx
import type { AppProps } from "next/app";
import { useEffect } from "react";

export default function MyApp({ Component, pageProps }: AppProps) {
  useEffect(() => {
    import("@s4e/jsentinel/auto");
  }, []);
  return <Component {...pageProps} />;
}
```

#### Next.js (App Router – `app/`)

Import through a Client Component provider:

```tsx
// app/providers.tsx
"use client";
import { useEffect } from "react";

export default function Providers({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    import("@s4e/jsentinel/auto");
  }, []);
  return <>{children}</>;
}
```

```tsx
// app/layout.tsx
import Providers from "./providers";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
```

#### React (Vite, CRA)

```tsx
// src/main.tsx or src/index.tsx
import "@s4e/jsentinel/auto";
// ... your root render
```

#### Vue 3

```ts
// src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import "@s4e/jsentinel/auto";

createApp(App).mount("#app");
```

#### Nuxt 3

Create a client-only plugin under `plugins/`:

```ts
// plugins/jsentinel.client.ts
export default defineNuxtPlugin(() => import("@s4e/jsentinel/auto"));
```

#### Angular

```ts
// src/main.ts
import "@s4e/jsentinel/auto";
// then bootstrapModule(...)
```

#### SvelteKit

```svelte
<!-- src/routes/+layout.svelte -->
<script>
  import { onMount } from "svelte";
  onMount(() => {
    import("@s4e/jsentinel/auto");
  });
}</script>

<slot />
```

#### Astro

Add `auto` as a module script:

```astro
---
// inside an .astro component
---
<script type="module">
  import "@s4e/jsentinel/auto";
  // or dynamic import()
  // await import("@s4e/jsentinel/auto");
  // (runs client-side only)
 </script>
```

#### Vanilla JS (ESM)

```js
import "@s4e/jsentinel/auto";
```

#### Classic script tag

Copy `node_modules/@s4e/jsentinel/dist/auto.js` to your `public/` and add:

```html
<script type="module" src="/auto.js"></script>
```

---

## Manifest

JSentinel reads expected script information from a JSON manifest (default path: `/manifest.json`). The schema is array-based to support same-origin resources, pinned CDNs and inline scripts.

Example:

```json
{
  "version": "2024-07-01T12:34:56.000Z",
  "scripts": [
    { "src": "/assets/app.js", "type": "same-origin", "hash": "sha256-..." },
    { "src": "/assets/vendor.js", "type": "same-origin", "hash": "sha256-..." },
    {
      "inline": "console.log('hi')",
      "type": "inline-stable",
      "hash": "sha256-..."
    },
    {
      "inline": "{\"@context\":\"https://schema.org\"}",
      "type": "inline-volatile"
    },
    {
      "src": "https://cdn.example.com/pkg@1.2.3/dist/index.min.js",
      "type": "versioned-cdn"
    },
    {
      "src": "https://www.googletagmanager.com/gtm.js?id=GTM-XXXX",
      "type": "dynamic-loader",
      "host": "www.googletagmanager.com",
      "path": "/gtm.js",
      "query": "id=GTM-XXXX"
    }
  ]
}
```

How matching works at runtime:

- Same‑origin scripts: JSentinel fetches the script (no-store), computes SHA‑256 and compares to `hash`. It also tolerates keys by pathname, decoded pathname, full URL, and as a last resort by filename.
- Inline scripts: contents are normalized (trim, EOLs, whitespace) before hashing. Entries marked `inline-stable` must match the provided `hash`. Entries marked `inline-volatile` are observed but mismatches are downgraded to warnings (reason: `volatile-inline`).
- Cross‑origin scripts: if not HTTPS → warn (`insecure-http`). If HTTPS: accepted when either URL is version-pinned (`versioned-cdn`) or matches a dynamic pin (`dynamic-loader` with host/path/query). If CORS allows, JSentinel may also include a computed `got` hash in events.

### Generate a manifest (CLI)

Use the companion CLI to generate the array-based manifest from your build output:

```bash
npm i -D @s4e/jsentinel-cli

# Examples
jsentinel manifest .next/static public --base-url /
jsentinel manifest --base-url / --inputs ".next/static,public" --output public/manifest.json
jsentinel manifest dist --base-url /app/ --exclude "**/*.map,**/*.txt"
```

Options:

- `--base-url <url>`: Base URL prefix for keys (default `/`). Next.js static is auto-mapped to `/_next/static/`.
- `--inputs <csv>`: Comma-separated input directories. Positionals are also accepted.
- `--output <file>`: Output file (default `public/manifest.json`).
- `--exclude <csv>`: Comma-separated glob patterns to exclude.

Recommended package.json script (in your app, not this library):

```json
{
  "scripts": {
    "build": "next build && jsentinel manifest .next/static public --base-url /"
  }
}
```

---

## API and events

All signals are dispatched through a single `CustomEvent("jsentinel")`. Helpers are provided for ergonomic listening.

Exported API:

- `initJSentinel(options?: JSentinelOptions): Promise<void>`
- `onJSentinel<T extends JSentinelMessage["type"]>(type, handler)`
- `onJSentinelAny(handler)`
- `offJSentinel(listener)`
- `attachConsoleLogger()`

Event types:

- `ready`
- `script.ok`
- `script.mismatch` (reason: `hash-different` | `manifest-missing-key` | `fetch-error` | `volatile-inline` | `url-pin-mismatch` | `insecure-http` | `unknown`)
- `scan.complete` → `{ total, ok, mismatched }`
- `clipboard.tamper` → `{ expected, got }`
- `clipboard.permission`
- `error` → `{ stage, message }`

Listening examples:

```ts
import {
  onJSentinel,
  onJSentinelAny,
  offJSentinel,
  attachConsoleLogger,
} from "@s4e/jsentinel";

const stopVerbose = attachConsoleLogger();

const removeMismatch = onJSentinel("script.mismatch", (data) => {
  console.warn("Mismatch:", data);
});

const removeAll = onJSentinelAny((msg) => {
  console.log("JSentinel:", msg);
});

// Later
offJSentinel(removeMismatch);
offJSentinel(removeAll);
offJSentinel(stopVerbose);
```

### Options

`initJSentinel(options?: JSentinelOptions)`

```ts
type JSentinelOptions = {
  manifestPath?: string; // default: "/manifest.json"
  watchClipboard?: boolean; // default: true
  verboseChecks?: boolean; // if true, emits additional "script.checked" telemetry per script
};
```

The `/auto` entry calls `initJSentinel()` with defaults.

---

## SSR and permissions

- The `/auto` entry can be imported in SSR environments; it guards for browser globals and only runs on the client.
- Clipboard reading depends on browser permissions and user interaction. If disallowed, a `clipboard.permission` event is emitted.
- To read cross-origin script contents, CORS must allow `fetch`. Without CORS, integrity checks for those scripts fallback to URL pin logic and still emit events.

## Browser support

- Targets modern browsers that implement `crypto.subtle.digest` and the Clipboard API.
- Older browsers may not be supported.

## FAQ

- What if the manifest is 404? → An `error` event is emitted with `stage: "init"`.
- Hash differs from expected? → `script.mismatch` with `reason: "hash-different"`.
- Missing key in manifest? → `script.mismatch` with `reason: "manifest-missing-key"`.
- How are inline scripts matched? → By hashing normalized contents; stable vs volatile classification comes from the manifest.

## TypeScript

Types are bundled. Event payloads and helper functions are fully typed.

## Developing this package

```bash
npm run build
# Produces ESM outputs and .d.ts under dist/
```

## Security note

This library does not replace browser-native defenses like CSP/SRI. It adds an observation layer. For best results, use together with a strict CSP, SRI, and a secure build/deploy pipeline.

## License

MIT
