@localnerve/trusted-types-bootstrap 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,11 @@
1
+ Copyright 2026 Alex Grant, LocalNerve, LLC
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10
+
11
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # Trusted Types Bootstrap
2
+
3
+ > Page-level Trusted Types bootstrap for web apps — one import + one config call replaces the per-app inline bootstrap script
4
+
5
+ [![npm version](https://badge.fury.io/js/@localnerve%2Ftrusted-types-bootstrap.svg)](https://badge.fury.io/js/@localnerve%2Ftrusted-types-bootstrap)
6
+
7
+ Every web app that enforces `require-trusted-types-for 'script'` re-implements the same boilerplate inline bootstrap script: detect whether CSP is enforced, register the reserved `default` policy (escaping `createHTML`, same-origin-only `createScriptURL`, no `createScript`), optionally register app-named policies for static-markup passthrough, install a passthrough shim for browsers without Trusted Types, and log `securitypolicyviolation` events.
8
+
9
+ This package turns that boilerplate into a shared, parameterized export: **one import + one config call**, with the logic identical for any app — only the named policies and allowed script origins vary per project. The core (escape, same-origin guard, shim, orchestration) is tiny and dependency-free; the DOMPurify-backed sanitize tier is an opt-in sub-path so apps without rich content never pay for it.
10
+
11
+ * [Why This Exists](#why-this-exists)
12
+ * [Usage](#usage)
13
+ * [API](#api)
14
+ * [Sanitize Sub-Path](#sanitize-sub-path)
15
+ * [Relationship to Other Packages](#relationship-to-other-packages)
16
+ * [Development](#development)
17
+
18
+ ## Why This Exists
19
+
20
+ 1. **Stop re-creating the same ~90-line inline bootstrap in every web app** — drift risk across projects, duplicated review effort
21
+ 2. **Zero cost for apps that don't need rich content** — the core escape guard + shim is a few KB and has no runtime dependencies
22
+ 3. **Opt-in sanitize tier** — DOMPurify-backed "allow some safe HTML" support lives in a separate entry point (`/sanitize`) that only lands in bundles of apps that import it
23
+ 4. **Compatible with computed allowlists** — named policies are declared at the call site (in your app's inline script), so [`@localnerve/trusted-types-rules`](https://github.com/localnerve/trusted-types-rules#readme) keeps detecting them via AST analysis for the CSP `trusted-types` directive — no special handling needed
24
+ 5. **ESM-first, tree-shakeable** for bundlers (Rollup/webpack)
25
+
26
+ ## Usage
27
+
28
+ The bootstrap must run **before any other app code** — keep it as the page's first inline script so policies exist when sinks are first hit. In production builds that meta tag is present and policies register; in dev builds (no meta tag) nothing registers and plain strings pass through, exactly like a browser without Trusted Types.
29
+
30
+ ```js
31
+ // your page's FIRST inline script
32
+ import { bootstrapTrustedTypes } from '@localnerve/trusted-types-bootstrap';
33
+
34
+ bootstrapTrustedTypes({
35
+ // 'auto' (default): enforce only when a CSP meta tag is present (prod builds).
36
+ // true/false to override.
37
+ enforce: 'auto',
38
+
39
+ // Origins allowed by the default policy's createScriptURL. Default: same-origin
40
+ // only. Extra entries are full origins, e.g. 'https://cdn.example.com'.
41
+ scriptURLOrigins: ['self'],
42
+
43
+ // App-named policies to register alongside `default`. Keys must appear in the
44
+ // CSP trusted-types allowlist (computed by @localnerve/trusted-types-rules).
45
+ policies: {
46
+ 'jam-app-static': 'staticHtml', // pass-through createHTML: author-controlled markup only
47
+ },
48
+
49
+ // optional per-policy form: custom hooks and/or a helper callback invoked
50
+ // after the policy is registered (e.g. to attach a convenience global)
51
+ // policies: {
52
+ // 'jam-app-static': {
53
+ // hooks: { createHTML: input => String(input) },
54
+ // helper: w => {
55
+ // w.trustedStaticHtml = html => w.trustedTypes.getPolicy('jam-app-static').createHTML(html);
56
+ // }
57
+ // }
58
+ // },
59
+
60
+ logViolations: true, // console.warn on securitypolicyviolation (dev aid)
61
+ shimWhenUnavailable: true // passthrough shim for browsers without TT
62
+ });
63
+ ```
64
+
65
+ The `default` policy's behavior contract:
66
+
67
+ * `createHTML` applies the industry-standard **6-char escape** (`& < > " ' \`` → `&amp; &lt; &gt; &quot; &#x27; &#x60;`) — safe in both element-content and attribute contexts, so markup sinks render inert text instead of executing injected tags
68
+ * `createScriptURL` only allows origins in `scriptURLOrigins` (default same-origin), so code can never be loaded from another origin (`<script src>`, Workers, dynamic `import()`)
69
+ * **no** `createScript` hook — `eval()` / `new Function()` remain blocked by CSP
70
+
71
+ Custom-element / web-component policies are unaffected: components register their own named policies at their own load time (via [`@localnerve/web-component-build`](https://github.com/localnerve/web-component-build#trusted-types-helpers) helpers), exactly as they do today.
72
+
73
+ ## API
74
+
75
+ This library exports:
76
+
77
+ ```js
78
+ export function bootstrapTrustedTypes (config): Boolean
79
+ export function defaultPolicyHooks (options): Object
80
+ export function setWindow (target)
81
+ export { escapeHtml, shimTrustedTypes }
82
+ ```
83
+
84
+ ### bootstrapTrustedTypes(config)
85
+ Bootstraps page-level Trusted Types. Returns `true` when policies were registered, `false` in passthrough mode (dev builds / browsers without TT). Throws if no window/document is available.
86
+
87
+ Config options:
88
+
89
+ * **enforce** {'auto'|Boolean} — default `'auto'`. `'auto'`: enforce only when a CSP meta tag (`meta[http-equiv="Content-Security-Policy"]`) is present, i.e. production builds; dev builds with no meta register nothing so plain strings pass through. `true`/`false` override detection.
90
+ * **scriptURLOrigins** {Array\<String\>} — origins allowed by the default policy's `createScriptURL`. Default `['self']` (same-origin only). Extra entries are full origins, e.g. `'https://cdn.example.com'`.
91
+ * **policies** {Object} — app-named policies to register alongside `default`. Keys must appear in the CSP `trusted-types` allowlist. Values are one of:
92
+ * `'staticHtml'` — pass-through preset: `createHTML` returns the input unchanged (author-controlled markup only; must never receive user-influenced data), plus a same-origin `createScriptURL`
93
+ * an **hooks object** — `{ createHTML, createScriptURL, createScript }` passed straight to `trustedTypes.createPolicy`
94
+ * `{ hooks, helper }` — hooks as above, plus an optional `helper(window)` callback invoked after registration (e.g. to attach a convenience global)
95
+ * **logViolations** {Boolean} — default `true`. `console.warn` on `securitypolicyviolation` events. Cheap development aid; the event never fires in a healthy prod environment.
96
+ * **shimWhenUnavailable** {Boolean} — default `true`. When enforcing but the browser has no Trusted Types (it ignores `require-trusted-types-for` anyway), install the passthrough shim so unconditional `trustedTypes.createPolicy(...)` calls elsewhere never throw and plain strings pass through as before.
97
+
98
+ ### defaultPolicyHooks(options)
99
+ Returns the hook object for the reserved `default` policy, for apps that want to register policies manually (e.g. with their own timing or a custom policy name set). Accepts `{ scriptURLOrigins }` as in the config above. No `createScript` hook is included.
100
+
101
+ ### escapeHtml(input)
102
+ Applies the industry-standard 6-character escape — `& < > " ' \`` → `&amp; &lt; &gt; &quot; &#x27; &#x60;` — for safe interpolation into HTML markup, in element content **and** attribute values. Output is byte-identical to [`he.escape`](https://github.com/mathiasbynens/he) for all inputs where `he` does not throw; `null`/`undefined` yield `''`. Exported here so build-time or computed contexts can use the same escape the runtime `default` policy applies.
103
+
104
+ ### shimTrustedTypes(window)
105
+ Installs the passthrough shim on a window (see [lib/shim.js](lib/shim.js)): `trustedTypes.createPolicy` returns plain-string pass-through hooks, and `document.createElement('script').src = x` never throws (the setter coerces to string). Idempotent — does nothing when `trustedTypes` already exists.
106
+
107
+ ### setWindow(target)
108
+ Overrides the window reference used for policy registration (resolved at call time, defaulting to `globalThis`). Primarily a test hook; apps do not need it.
109
+
110
+ ## Sanitize Sub-Path
111
+
112
+ The **escape tier** (default behavior) is strict: six characters escaped, nothing else rendered as markup. For rendering **untrusted rich content** (user-supplied posts, importable documents) you want the **sanitize tier**: DOMPurify strips dangerous markup while preserving an allow-list of safe HTML. It lives behind a separate entry point so only apps that import it pay the ~11 KB gzip cost:
113
+
114
+ ```js
115
+ // Only this module imports dompurify (~11 KB gzip). Apps without rich content
116
+ // never pay for it.
117
+ import { createSanitizerPolicy } from '@localnerve/trusted-types-bootstrap/sanitize';
118
+
119
+ const policy = createSanitizerPolicy('app-rich', {
120
+ USE_PROFILES: { html: true }, // DOMPurify config passthrough
121
+ ALLOWED_TAGS: ['b', 'i', 'em', 'strong']
122
+ });
123
+
124
+ // at the sink:
125
+ el.innerHTML = policy.createHTML(dirtyHtml);
126
+ ```
127
+
128
+ * **createSanitizerPolicy(name, dompurifyConfig)** — creates a named policy whose `createHTML` calls `DOMPurify.sanitize(input, { ...config, TRUSTED_TYPES_POLICY: null })`. The `TRUSTED_TYPES_POLICY: null` is forced internally (any caller-supplied value is ignored) so DOMPurify does **not** create its internal `dompurify` policy — which would break a computed CSP allowlist. Returns `null` when Trusted Types is unavailable; fall back to plain-string passthrough or use the exported `purify` instance directly.
129
+ * **purify** — a `DOMPurify` instance bound to the current window, for sanitizing without a policy (e.g. dev builds without enforcement).
130
+
131
+ The policy name is the first argument and must be in the CSP allowlist, detected by `@localnerve/trusted-types-rules` as usual since it is declared at this call site.
132
+
133
+ ## Relationship to Other Packages
134
+
135
+ | Package | Scope | TT role |
136
+ | --- | --- | --- |
137
+ | [`@localnerve/web-component-build`](https://github.com/localnerve/web-component-build) | web component build pipeline | **Component-level** runtime helpers (`escapeHtml`, `getTrustedPolicy`, `trustedHtml`) inlined into each component's bundle; components register their own named policy. |
138
+ | `@localnerve/trusted-types-bootstrap` (this) | app/page-level bootstrap | Registers the app's `default` + named policies, shim, violation logging. One call per app. |
139
+ | [`@localnerve/trusted-types-rules`](https://github.com/localnerve/trusted-types-rules) | build-time audit | Computes the CSP `trusted-types` allowlist from built JS (AST). Consumes output of both above; no special handling needed. |
140
+
141
+ The 6-char escape exists in two places by design: wcb's `escapeHtml` (component-level, build-time-inlined) and this package's core (app-level, runtime). They must stay byte-identical — a shared test vector (`ESCAPE_VECTORS` in [`__tests__/escape.spec.js`](__tests__/escape.spec.js), verified against `he.escape`) guards that drift.
142
+
143
+ ## Development
144
+
145
+ ```sh
146
+ npm install
147
+ npm test # node --test with coverage (lcov written to coverage/)
148
+ npm run lint # eslint
149
+ ```
150
+
151
+ Tests run under Node (see `.nvmrc`); the browser path is exercised with jsdom plus a fake `trustedTypes` recorder — jsdom has no native Trusted Types, and policy registration through `createPolicy` + hooks is exactly the API surface this package relies on.
152
+
153
+ ## License
154
+
155
+ BSD-3-Clause — see [LICENSE.md](LICENSE.md).
156
+
157
+ Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
package/index.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Page-level Trusted Types bootstrap.
3
+ *
4
+ * One import + one config call replaces the per-app inline bootstrap script:
5
+ * registers the reserved `default` policy (escaping createHTML, allowlisted
6
+ * createScriptURL, no createScript), optional app-named policies, a passthrough
7
+ * shim for browsers without Trusted Types, and securitypolicyviolation logging.
8
+ *
9
+ * The default config replicates jam-build's inline bootstrap exactly:
10
+ * - `enforce: 'auto'` registers policies only when a CSP meta tag is present
11
+ * (production builds); dev builds with no meta register nothing so plain
12
+ * strings pass through.
13
+ * - Named policies are declared at the call site, so
14
+ * @localnerve/trusted-types-rules keeps detecting them via AST analysis for
15
+ * the computed CSP `trusted-types` allowlist.
16
+ *
17
+ * This module must run before any other app code (e.g. as the page's first
18
+ * inline script) so policies exist when sinks are first hit.
19
+ *
20
+ * Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
21
+ * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
22
+ */
23
+
24
+ import { escapeHtml } from './lib/escape.js';
25
+ import { shimTrustedTypes } from './lib/shim.js';
26
+
27
+ // window is resolved at call time (not import time) so bundlers and tests can
28
+ // control it; setWindow() overrides the global reference (used by tests).
29
+ let win = typeof globalThis !== 'undefined' ? globalThis : null;
30
+
31
+ /**
32
+ * Override the window reference used for policy registration.
33
+ *
34
+ * @param {Window} target - The window to register policies on (or null to reset to globalThis).
35
+ */
36
+ export function setWindow (target) {
37
+ win = target === undefined ? (typeof globalThis !== 'undefined' ? globalThis : null) : target;
38
+ }
39
+
40
+ /**
41
+ * Hooks for the reserved `default` policy.
42
+ *
43
+ * - createHTML: industry-standard 6-char escape, safe in element-content and
44
+ * attribute contexts.
45
+ * - createScriptURL: only origins in scriptURLOrigins pass (default same-origin).
46
+ * - No createScript hook: eval() / new Function() stay blocked by CSP.
47
+ *
48
+ * @param {Object} [options] - Options.
49
+ * @param {Array<String>} [options.scriptURLOrigins] - Origins allowed by createScriptURL. 'self' means same-origin (default). Extra entries are full origins, e.g. 'https://cdn.example.com'.
50
+ * @returns {Object} The policy hooks object.
51
+ */
52
+ export function defaultPolicyHooks (options = {}) {
53
+ const origins = options.scriptURLOrigins || ['self'];
54
+ return {
55
+ createHTML: input => escapeHtml(input),
56
+ createScriptURL: (input, opts) => {
57
+ const url = new URL(String(input), opts?.baseUrl || win?.location?.href || 'http://localhost/');
58
+ const allowed = origins.some(o => o === 'self' ? url.origin === win.location.origin : url.origin === o);
59
+ if (!allowed) {
60
+ throw new TypeError('Trusted Types: script URL rejected by default policy: ' + url);
61
+ }
62
+ return url.href;
63
+ }
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Build the hook object for a named policy from its config entry.
69
+ *
70
+ * @param {Object|String} spec - Either a hooks object ({createHTML, createScriptURL, createScript}) or 'staticHtml' for the pass-through preset (author-controlled markup only; must never receive user-influenced data).
71
+ * @returns {Object} The policy hooks object.
72
+ */
73
+ function namedPolicyHooks (spec) {
74
+ if (typeof spec === 'string') {
75
+ if (spec !== 'staticHtml') {
76
+ throw new TypeError('trusted-types-bootstrap: unknown preset "' + spec + '"');
77
+ }
78
+ return {
79
+ createHTML: input => String(input),
80
+ createScriptURL: input => new URL(String(input), win.location.href).href
81
+ };
82
+ }
83
+ return spec;
84
+ }
85
+
86
+ /**
87
+ * Bootstrap page-level Trusted Types.
88
+ *
89
+ * @param {Object} [config] - Configuration.
90
+ * @param {'auto'|Boolean} [config.enforce='auto'] - 'auto' (default): enforce only when a CSP meta tag (meta[http-equiv="Content-Security-Policy"]) is present, i.e. production builds. true/false override detection.
91
+ * @param {Array<String>} [config.scriptURLOrigins] - Origins allowed by the default policy's createScriptURL. Default ['self'] (same-origin only).
92
+ * @param {Object} [config.policies] - App-named policies to register alongside `default`. Keys must appear in the CSP trusted-types allowlist (computed by @localnerve/trusted-types-rules). Values: 'staticHtml' preset, a hooks object, or {hooks, helper}.
93
+ * @param {Boolean} [config.logViolations=true] - console.warn on securitypolicyviolation events (development aid; cheap and the event never fires in healthy prod).
94
+ * @param {Boolean} [config.shimWhenUnavailable=true] - Install the passthrough shim when the browser has no Trusted Types, so unconditional createPolicy calls never throw.
95
+ */
96
+ export function bootstrapTrustedTypes (config = {}) {
97
+ const {
98
+ enforce = 'auto',
99
+ scriptURLOrigins,
100
+ policies = {},
101
+ logViolations = true,
102
+ shimWhenUnavailable = true
103
+ } = config;
104
+
105
+ if (!win || typeof win.document === 'undefined') {
106
+ throw new TypeError('trusted-types-bootstrap: no window/document available');
107
+ }
108
+
109
+ const hasTT = 'trustedTypes' in win;
110
+ const cspEnforced = !!win.document.querySelector('meta[http-equiv="Content-Security-Policy"]');
111
+ const shouldEnforce = enforce === 'auto' ? cspEnforced : Boolean(enforce);
112
+
113
+ if (logViolations) {
114
+ win.addEventListener('securitypolicyviolation', e => console.warn(
115
+ 'CSP violation:', e.violatedDirective, e.blockedURI || ''
116
+ ));
117
+ }
118
+
119
+ if (!shouldEnforce) return false; // passthrough (dev / no CSP meta): nothing to register
120
+
121
+ if (!hasTT) {
122
+ // enforcing, but the browser has no Trusted Types (it ignores
123
+ // require-trusted-types-for anyway): shim so unconditional createPolicy
124
+ // calls elsewhere never throw and plain strings pass through as before.
125
+ if (shimWhenUnavailable) shimTrustedTypes(win);
126
+ return false;
127
+ }
128
+
129
+ win.trustedTypes.createPolicy('default', defaultPolicyHooks({ scriptURLOrigins }));
130
+
131
+ for (const [name, value] of Object.entries(policies)) {
132
+ const hooks = typeof value === 'object' && value !== null ? value.hooks : value;
133
+ const helper = typeof value === 'object' && value !== null ? value.helper : null;
134
+ win.trustedTypes.createPolicy(name, namedPolicyHooks(hooks));
135
+ if (typeof helper === 'function') helper(win);
136
+ }
137
+
138
+ return true;
139
+ }
140
+
141
+ export { escapeHtml, shimTrustedTypes };
package/lib/escape.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Industry-standard HTML escaping - single source of truth for this package.
3
+ *
4
+ * MUST stay byte-identical to @localnerve/web-component-build's escapeHtml
5
+ * (the component-level, build-time-inlined copy). The shared test vector in
6
+ * __tests__/escape.spec.js guards against drift between the two.
7
+ *
8
+ * Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
9
+ * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
10
+ */
11
+
12
+ /**
13
+ * Escape a value for safe interpolation into HTML markup (text or attribute).
14
+ *
15
+ * Applies the industry-standard escape of all six characters that can break
16
+ * out of a text or attribute context: & < > " ' and backtick. Escaping quotes
17
+ * and the backtick (not just &lt;/&gt;) is what makes the result safe to reuse
18
+ * inside an attribute value, not only element content. Output is byte-identical
19
+ * to he.escape() for all inputs where he does not throw (null/undefined).
20
+ *
21
+ * @param {Any} input - The value to escape
22
+ * @returns {String} The escaped string. null/undefined yield ''.
23
+ */
24
+ export function escapeHtml (input) {
25
+ if (input === null || typeof input === 'undefined') return '';
26
+ return String(input)
27
+ .replace(/&/g, '&amp;')
28
+ .replace(/</g, '&lt;')
29
+ .replace(/>/g, '&gt;')
30
+ .replace(/"/g, '&quot;')
31
+ .replace(/'/g, '&#x27;')
32
+ .replace(/`/g, '&#x60;');
33
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Opt-in DOMPurify-backed sanitizer policy.
3
+ *
4
+ * Only this module imports dompurify (~11 KB gzip minified); apps that never
5
+ * import the `/sanitize` sub-path do not pay for it (separate entry point,
6
+ * tree-shakeable). Use this tier to render untrusted rich content (user posts,
7
+ * imported documents) - never plain escaping for rich markup.
8
+ *
9
+ * TT integration: sanitize() is called inside this policy's own createHTML with
10
+ * TRUSTED_TYPES_POLICY: null so DOMPurify does NOT create its internal
11
+ * `dompurify` policy (which would break a computed CSP allowlist).
12
+ *
13
+ * Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
14
+ * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
15
+ */
16
+
17
+ import DOMPurify from 'dompurify';
18
+
19
+ /**
20
+ * Create a named Trusted Types policy that sanitizes input with DOMPurify.
21
+ *
22
+ * The policy name must appear in the CSP `trusted-types` allowlist (detected
23
+ * by @localnerve/trusted-types-rules as usual - it is declared at this call site).
24
+ *
25
+ * @param {String} name - The policy name (CSP-allowlisted).
26
+ * @param {Object} [config] - DOMPurify configuration passthrough, e.g. {USE_PROFILES: {html: true}, ALLOWED_TAGS: ['b', 'i']}. TRUSTED_TYPES_POLICY is always forced to null; any value passed for it is ignored.
27
+ * @returns {TrustedTypePolicy|null} The policy when Trusted Types is available, otherwise null (callers should fall back to DOMPurify.sanitize directly, or a passthrough string).
28
+ */
29
+ export function createSanitizerPolicy (name, config = {}) {
30
+ const w = typeof window !== 'undefined' ? window : null;
31
+ if (!w || !('trustedTypes' in w)) return null;
32
+
33
+ const dompurify = DOMPurify(w); // bind to the app's window
34
+
35
+ return w.trustedTypes.createPolicy(name, {
36
+ createHTML: input => dompurify.sanitize(String(input), {
37
+ ...config,
38
+ TRUSTED_TYPES_POLICY: null
39
+ })
40
+ });
41
+ }
42
+
43
+ /**
44
+ * DOMPurify instance bound to the current window. Exposed so callers can also
45
+ * sanitize without a policy (e.g. in dev builds without enforcement).
46
+ */
47
+ export const purify = typeof window !== 'undefined' ? DOMPurify(window) : null;
package/lib/shim.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Passthrough shim for browsers without Trusted Types support.
3
+ *
4
+ * Browsers without Trusted Types ignore `require-trusted-types-for`, so plain
5
+ * strings pass through all sinks as before; the shim exists purely so code that
6
+ * calls `trustedTypes.createPolicy(...)` unconditionally (e.g. this package's
7
+ * policy registration, or web components built with wcb) never throws in those
8
+ * browsers.
9
+ *
10
+ * Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
11
+ * Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
12
+ */
13
+
14
+ /**
15
+ * Install a passthrough Trusted Types shim on a window.
16
+ *
17
+ * - `trustedTypes.createPolicy` returns plain-string pass-through hooks, so
18
+ * policy registration is a no-op with the same call signature as the real API.
19
+ * - `document.createElement('script').src = x` never throws: the setter coerces
20
+ * to a string, guarding against a sink ever receiving an unexpected type.
21
+ *
22
+ * @param {Window} w - The window object to patch.
23
+ */
24
+ export function shimTrustedTypes (w) {
25
+ if ('trustedTypes' in w) return; // already available: nothing to shim
26
+
27
+ const tt = {
28
+ createPolicy: () => ({
29
+ createHTML: s => String(s),
30
+ createScript: s => String(s),
31
+ createScriptURL: s => String(s)
32
+ })
33
+ };
34
+ Object.defineProperty(w, 'trustedTypes', { value: tt, writable: true });
35
+
36
+ // keep <script src> assignment from throwing on a non-string in case a sink
37
+ // ever receives an unexpected type; everything else passes through untouched
38
+ const origCreateElement = w.document.createElement.bind(w.document);
39
+ w.document.createElement = (tagName, ...rest) => {
40
+ const el = origCreateElement(tagName, ...rest);
41
+ if (/^script$/i.test(tagName)) {
42
+ Object.defineProperty(el, 'src', {
43
+ set (v) {
44
+ try {
45
+ el.setAttribute('src', String(v));
46
+ } catch { /* ignore */ }
47
+ },
48
+ get () {
49
+ return el.getAttribute('src');
50
+ }
51
+ });
52
+ }
53
+ return el;
54
+ };
55
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@localnerve/trusted-types-bootstrap",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "Page-level Trusted Types bootstrap for web apps: one import + config call replaces the per-app inline bootstrap script. Optional DOMPurify-backed sanitizer policy sub-path.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./sanitize": "./lib/sanitize.js"
10
+ },
11
+ "scripts": {
12
+ "lint": "eslint .",
13
+ "pretest": "node -e \"const fs=require('fs');fs.rmSync('./coverage',{recursive:true,force:true});fs.mkdirSync('./coverage',{recursive:true})\"",
14
+ "test": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-coverage-exclude=\"__tests__/**\" ./__tests__/*.spec.js"
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "lib"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/localnerve/trusted-types-bootstrap.git"
23
+ },
24
+ "keywords": [
25
+ "trusted types",
26
+ "csp",
27
+ "content security policy",
28
+ "xss",
29
+ "sanitize",
30
+ "web security",
31
+ "bootstrap"
32
+ ],
33
+ "author": {
34
+ "name": "Alex Grant",
35
+ "email": "alex@localnerve.com",
36
+ "url": "https://www.localnerve.com"
37
+ },
38
+ "maintainers": [
39
+ {
40
+ "name": "Alex Grant",
41
+ "email": "alex@localnerve.com",
42
+ "url": "https://www.localnerve.com"
43
+ }
44
+ ],
45
+ "license": "BSD-3-Clause",
46
+ "bugs": {
47
+ "url": "https://github.com/localnerve/trusted-types-bootstrap/issues"
48
+ },
49
+ "homepage": "https://github.com/localnerve/trusted-types-bootstrap#readme",
50
+ "dependencies": {
51
+ "dompurify": "^3.4.14"
52
+ },
53
+ "devDependencies": {
54
+ "@eslint/js": "^10.0.1",
55
+ "eslint": "^10.9.1",
56
+ "eslint-plugin-n": "^18.3.0",
57
+ "globals": "^17.12.0",
58
+ "he": "^1.2.0",
59
+ "jsdom": "^30.0.1"
60
+ },
61
+ "funding": [
62
+ {
63
+ "type": "github",
64
+ "url": "https://github.com/sponsors/localnerve"
65
+ },
66
+ {
67
+ "type": "paypal",
68
+ "url": "https://www.paypal.com/ncp/payment/DHCB5GUYMGX5U"
69
+ }
70
+ ],
71
+ "engines": {
72
+ "node": ">=22"
73
+ }
74
+ }