Skip to content

Commit 7d3db03

Browse files
feat(#6,#7): CSP directive catalog + nonce + defer/consent strategies
- src/csp.ts (ahize/csp sub-path): per-provider CspDirectives catalog (script/connect/frame/style/img/font/media-src) including every WSS endpoint competitor wrappers forget (nexus-websocket-a/b.intercom.io, client.relay.crisp.chat, widget-mediator.zopim.com, <host>/cable for Chatwoot). mergeCsp(), toHeaderString() and watchCspViolations() helpers. - nonce forwarded to every injected <script> tag via options.nonce. - BaseLoadOptions adds defer ('immediate'|'idle'|'interaction'|'manual') and consent (boolean gate). waitForDefer() uses requestIdleCallback with setTimeout(200) fallback, pointerdown/scroll/keydown/touchstart with 10s fallback for 'interaction', blocks forever for 'manual'. - consent: false short-circuits load() before any script injection. - Tests: CSP header construction, merge dedup, chatwoot self-hosted host, defer immediate/idle/interaction timings, consent gate. Preempts: crisp-sdk-web#5/#31, react-zendesk#35, vue-zendesk#36, react-use-intercom#237/#364/#741, adamsoffer/react-hubspot#6, react-use-hubspot-form#44. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c532a7c commit 7d3db03

14 files changed

Lines changed: 424 additions & 0 deletions

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@
7575
"./server": {
7676
"types": "./dist/server.d.mts",
7777
"default": "./dist/server.mjs"
78+
},
79+
"./csp": {
80+
"types": "./dist/csp.d.mts",
81+
"default": "./dist/csp.mjs"
7882
}
7983
},
8084
"scripts": {

src/_defer.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { isBrowser } from "./_loader.ts";
2+
3+
export type DeferStrategy = "immediate" | "idle" | "interaction" | "manual";
4+
5+
interface WindowWithIdle {
6+
requestIdleCallback?: (cb: () => void, opts?: { timeout?: number }) => number;
7+
cancelIdleCallback?: (id: number) => void;
8+
setTimeout: (cb: () => void, ms: number) => number;
9+
clearTimeout: (id: number) => void;
10+
addEventListener?: (type: string, listener: () => void, opts?: unknown) => void;
11+
removeEventListener?: (type: string, listener: () => void, opts?: unknown) => void;
12+
}
13+
14+
function win(): WindowWithIdle | undefined {
15+
if (!isBrowser()) return undefined;
16+
return globalThis as unknown as WindowWithIdle;
17+
}
18+
19+
const INTERACTION_EVENTS = ["pointerdown", "scroll", "keydown", "touchstart"] as const;
20+
21+
export function waitForDefer(strategy: DeferStrategy, timeoutMs = 10_000): Promise<void> {
22+
if (strategy === "immediate") return Promise.resolve();
23+
if (strategy === "manual") return new Promise(() => {});
24+
const w = win();
25+
if (!w) return Promise.resolve();
26+
27+
if (strategy === "idle") {
28+
return new Promise<void>((resolve) => {
29+
if (w.requestIdleCallback) {
30+
w.requestIdleCallback(() => resolve(), { timeout: timeoutMs });
31+
} else {
32+
w.setTimeout(() => resolve(), 200);
33+
}
34+
});
35+
}
36+
37+
return new Promise<void>((resolve) => {
38+
let settled = false;
39+
const off: Array<() => void> = [];
40+
const cleanup = () => {
41+
if (settled) return;
42+
settled = true;
43+
for (const fn of off) fn();
44+
resolve();
45+
};
46+
for (const evt of INTERACTION_EVENTS) {
47+
const handler = () => cleanup();
48+
w.addEventListener?.(evt, handler, { once: true, passive: true });
49+
off.push(() => w.removeEventListener?.(evt, handler));
50+
}
51+
const timer = w.setTimeout(cleanup, timeoutMs);
52+
off.push(() => w.clearTimeout(timer));
53+
});
54+
}

src/_types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,21 @@ export type IdentityState = { kind: "anonymous" } | { kind: "identified"; identi
3030

3131
export type IdentityListener = (next: IdentityState, prev: IdentityState) => void;
3232

33+
export type DeferStrategy = "immediate" | "idle" | "interaction" | "manual";
34+
3335
export interface BaseLoadOptions {
3436
nonce?: string;
3537
autoShow?: boolean;
38+
/**
39+
* When to actually inject the CDN script:
40+
* - "immediate" (default): inject right away
41+
* - "idle": requestIdleCallback + 200ms fallback
42+
* - "interaction": first pointerdown/scroll/keydown/touchstart
43+
* - "manual": the returned Promise never resolves; consumer must call resume()
44+
*/
45+
defer?: DeferStrategy;
46+
/** Consent gate. If false, load() resolves without injecting. Default: true. */
47+
consent?: boolean;
3648
}
3749

3850
export interface LoadOptions extends BaseLoadOptions {

src/csp.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import type { ProviderName } from "./_types.ts";
2+
3+
export type CspDirectiveKey =
4+
| "script-src"
5+
| "connect-src"
6+
| "frame-src"
7+
| "style-src"
8+
| "img-src"
9+
| "font-src"
10+
| "media-src";
11+
12+
export type CspDirectives = Record<CspDirectiveKey, readonly string[]>;
13+
14+
const EMPTY: CspDirectives = {
15+
"script-src": [],
16+
"connect-src": [],
17+
"frame-src": [],
18+
"style-src": [],
19+
"img-src": [],
20+
"font-src": [],
21+
"media-src": [],
22+
};
23+
24+
const CATALOG: Partial<Record<ProviderName, CspDirectives>> = {
25+
intercom: {
26+
"script-src": ["https://widget.intercom.io", "https://js.intercomcdn.com"],
27+
"connect-src": [
28+
"https://api-iam.intercom.io",
29+
"https://api-ping.intercom.io",
30+
"https://nexus-websocket-a.intercom.io",
31+
"https://nexus-websocket-b.intercom.io",
32+
"wss://nexus-websocket-a.intercom.io",
33+
"wss://nexus-websocket-b.intercom.io",
34+
"https://uploads.intercomcdn.com",
35+
"https://uploads.intercomusercontent.com",
36+
],
37+
"frame-src": ["https://intercom-sheets.com", "https://www.intercom-reporting.com"],
38+
"style-src": ["https://fonts.intercomcdn.com"],
39+
"img-src": ["https://js.intercomcdn.com", "https://static.intercomassets.com"],
40+
"font-src": ["https://fonts.intercomcdn.com"],
41+
"media-src": ["https://js.intercomcdn.com"],
42+
},
43+
crisp: {
44+
"script-src": ["https://client.crisp.chat"],
45+
"connect-src": [
46+
"https://client.crisp.chat",
47+
"https://client.relay.crisp.chat",
48+
"wss://client.relay.crisp.chat",
49+
"https://storage.crisp.chat",
50+
],
51+
"frame-src": ["https://game.crisp.chat"],
52+
"style-src": ["https://client.crisp.chat"],
53+
"img-src": [
54+
"https://client.crisp.chat",
55+
"https://image.crisp.chat",
56+
"https://storage.crisp.chat",
57+
],
58+
"font-src": ["https://client.crisp.chat"],
59+
"media-src": ["https://client.crisp.chat"],
60+
},
61+
tawk: {
62+
"script-src": ["https://embed.tawk.to", "https://*.tawk.to"],
63+
"connect-src": ["https://*.tawk.to", "wss://*.tawk.to"],
64+
"frame-src": ["https://*.tawk.to"],
65+
"style-src": ["https://embed.tawk.to"],
66+
"img-src": ["https://*.tawk.to", "https://*.tawkcdn.com"],
67+
"font-src": ["https://embed.tawk.to"],
68+
"media-src": ["https://*.tawkcdn.com"],
69+
},
70+
zendesk: {
71+
"script-src": ["https://static.zdassets.com", "https://ekr.zdassets.com"],
72+
"connect-src": [
73+
"https://*.zendesk.com",
74+
"https://*.zopim.com",
75+
"https://ekr.zdassets.com",
76+
"https://static.zdassets.com",
77+
"wss://widget-mediator.zopim.com",
78+
"wss://*.zopim.com",
79+
],
80+
"frame-src": ["https://*.zendesk.com", "https://*.zopim.com"],
81+
"style-src": ["https://static.zdassets.com"],
82+
"img-src": ["https://*.zdassets.com", "https://*.zendesk.com", "https://*.zopim.com"],
83+
"font-src": ["https://static.zdassets.com"],
84+
"media-src": ["https://*.zendesk.com"],
85+
},
86+
hubspot: {
87+
"script-src": [
88+
"https://js.hs-scripts.com",
89+
"https://js-eu1.hs-scripts.com",
90+
"https://js.hs-analytics.net",
91+
"https://js.hs-banner.com",
92+
"https://js.usemessages.com",
93+
"https://js.hsforms.net",
94+
"https://js.hs-scripts.com",
95+
"https://js.hubspot.com",
96+
],
97+
"connect-src": [
98+
"https://*.hubspot.com",
99+
"https://*.hubapi.com",
100+
"https://*.hs-analytics.net",
101+
"https://api.hubspot.com",
102+
"https://api.hubapi.com",
103+
"wss://*.hubspot.com",
104+
],
105+
"frame-src": ["https://app.hubspot.com", "https://*.hubspot.com"],
106+
"style-src": ["https://*.hubspot.com", "https://*.hsforms.net"],
107+
"img-src": ["https://*.hubspot.com", "https://*.hs-analytics.net", "https://track.hubspot.com"],
108+
"font-src": ["https://*.hubspot.com", "https://fonts.hubspot.com"],
109+
"media-src": ["https://*.hubspot.com"],
110+
},
111+
chatwoot: {
112+
"script-src": [],
113+
"connect-src": [],
114+
"frame-src": [],
115+
"style-src": [],
116+
"img-src": [],
117+
"font-src": [],
118+
"media-src": [],
119+
},
120+
};
121+
122+
export interface CspOptions {
123+
/** Include 'self' in each directive. Default: true. */
124+
includeSelf?: boolean;
125+
/** Override Chatwoot host for self-hosted directives. */
126+
chatwootBaseUrl?: string;
127+
}
128+
129+
export function cspDirectives(provider: ProviderName, options?: CspOptions): CspDirectives {
130+
const includeSelf = options?.includeSelf ?? true;
131+
const base = CATALOG[provider] ?? EMPTY;
132+
133+
if (provider === "chatwoot") {
134+
const baseUrl = options?.chatwootBaseUrl?.replace(/\/+$/, "") ?? "https://app.chatwoot.com";
135+
const host = new URL(baseUrl).host;
136+
const wss = `wss://${host}/cable`;
137+
const directives: CspDirectives = {
138+
"script-src": [baseUrl],
139+
"connect-src": [baseUrl, wss],
140+
"frame-src": [baseUrl],
141+
"style-src": [baseUrl],
142+
"img-src": [baseUrl],
143+
"font-src": [baseUrl],
144+
"media-src": [baseUrl],
145+
};
146+
return withSelf(directives, includeSelf);
147+
}
148+
149+
return withSelf(base, includeSelf);
150+
}
151+
152+
function withSelf(directives: CspDirectives, includeSelf: boolean): CspDirectives {
153+
if (!includeSelf) return directives;
154+
const out = {} as CspDirectives;
155+
for (const key of Object.keys(directives) as CspDirectiveKey[]) {
156+
out[key] = ["'self'", ...directives[key]];
157+
}
158+
return out;
159+
}
160+
161+
export function toHeaderString(directives: CspDirectives): string {
162+
const parts: string[] = [];
163+
for (const key of Object.keys(directives) as CspDirectiveKey[]) {
164+
const values = directives[key];
165+
if (values.length > 0) parts.push(`${key} ${values.join(" ")}`);
166+
}
167+
return parts.join("; ");
168+
}
169+
170+
export function mergeCsp(...sets: CspDirectives[]): CspDirectives {
171+
const out = {} as CspDirectives;
172+
const allKeys: CspDirectiveKey[] = [
173+
"script-src",
174+
"connect-src",
175+
"frame-src",
176+
"style-src",
177+
"img-src",
178+
"font-src",
179+
"media-src",
180+
];
181+
for (const key of allKeys) {
182+
const merged = new Set<string>();
183+
for (const set of sets) for (const v of set[key]) merged.add(v);
184+
out[key] = [...merged];
185+
}
186+
return out;
187+
}
188+
189+
export function watchCspViolations(
190+
handler: (event: SecurityPolicyViolationEvent) => void,
191+
): () => void {
192+
if (typeof window === "undefined") return () => {};
193+
const cast = handler as () => void;
194+
window.addEventListener?.("securitypolicyviolation", cast);
195+
return () => window?.removeEventListener?.("securitypolicyviolation", cast);
196+
}
197+
198+
interface SecurityPolicyViolationEvent {
199+
blockedURI: string;
200+
violatedDirective: string;
201+
effectiveDirective: string;
202+
originalPolicy: string;
203+
}

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { AhizeError, ProviderNotLoadedError, ScriptLoadError } from "./errors.ts";
22
export type {
33
BaseLoadOptions,
4+
DeferStrategy,
45
EventMetadata,
56
Identity,
67
IdentityListener,
@@ -23,5 +24,15 @@ export {
2324
type LifecycleListener,
2425
type LifecycleState,
2526
} from "./_lifecycle.ts";
27+
export { waitForDefer } from "./_defer.ts";
28+
export {
29+
cspDirectives,
30+
mergeCsp,
31+
toHeaderString,
32+
watchCspViolations,
33+
type CspDirectiveKey,
34+
type CspDirectives,
35+
type CspOptions,
36+
} from "./csp.ts";
2637

2738
export const version = "0.0.1";

src/providers/chatwoot.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { waitForDefer } from "../_defer.ts";
12
import { createIdentityStore } from "../_identity.ts";
23
import { createLifecycle, hashConfig } from "../_lifecycle.ts";
34
import { injectScript, isBrowser, removeScript } from "../_loader.ts";
@@ -59,6 +60,7 @@ export interface ChatwootLoadOptions extends LoadOptions {
5960

6061
export async function load(options: ChatwootLoadOptions): Promise<void> {
6162
if (!isBrowser()) return;
63+
if (options.consent === false) return;
6264
const baseUrl = normalizeBaseUrl(options.baseUrl ?? "https://app.chatwoot.com");
6365
const h = hashConfig({ websiteToken: options.websiteToken, baseUrl });
6466
if (lifecycle.state() === "ready" && lifecycle.configHash() === h) return;
@@ -68,6 +70,7 @@ export async function load(options: ChatwootLoadOptions): Promise<void> {
6870
lifecycle.setConfigHash(h);
6971
currentToken = options.websiteToken;
7072
currentBaseUrl = baseUrl;
73+
await waitForDefer(options.defer ?? "immediate");
7174
if (options.settings) w().chatwootSettings = options.settings;
7275

7376
readyListener = () => {

src/providers/crisp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { waitForDefer } from "../_defer.ts";
12
import { createIdentityStore } from "../_identity.ts";
23
import { createLifecycle, hashConfig } from "../_lifecycle.ts";
34
import { injectScript, isBrowser, removeScript } from "../_loader.ts";
@@ -43,12 +44,14 @@ export interface CrispLoadOptions extends LoadOptions {
4344

4445
export async function load(options: CrispLoadOptions): Promise<void> {
4546
if (!isBrowser()) return;
47+
if (options.consent === false) return;
4648
const h = hashConfig({ websiteId: options.websiteId, tokenId: options.tokenId });
4749
if (lifecycle.state() === "ready" && lifecycle.configHash() === h) return;
4850
if (lifecycle.configHash() && lifecycle.configHash() !== h) await destroy();
4951

5052
lifecycle.transition("loading");
5153
lifecycle.setConfigHash(h);
54+
await waitForDefer(options.defer ?? "immediate");
5255
bus();
5356
w().CRISP_WEBSITE_ID = options.websiteId;
5457
if (options.tokenId) w().CRISP_TOKEN_ID = options.tokenId;

src/providers/hubspot.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { waitForDefer } from "../_defer.ts";
12
import { createIdentityStore } from "../_identity.ts";
23
import { createLifecycle, hashConfig } from "../_lifecycle.ts";
34
import { injectScript, isBrowser, removeScript } from "../_loader.ts";
@@ -57,12 +58,14 @@ function lowercaseKeys<T extends Record<string, unknown>>(obj: T): Record<string
5758

5859
export async function load(options: HubSpotLoadOptions): Promise<void> {
5960
if (!isBrowser()) return;
61+
if (options.consent === false) return;
6062
const h = hashConfig({ portalId: options.portalId, region: options.region });
6163
if (lifecycle.state() === "ready" && lifecycle.configHash() === h) return;
6264
if (lifecycle.configHash() && lifecycle.configHash() !== h) await destroy();
6365

6466
lifecycle.transition("loading");
6567
lifecycle.setConfigHash(h);
68+
await waitForDefer(options.defer ?? "immediate");
6669

6770
w().hsConversationsSettings = { loadImmediately: false };
6871

src/providers/intercom.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { waitForDefer } from "../_defer.ts";
12
import { createIdentityStore } from "../_identity.ts";
23
import { createLifecycle, hashConfig } from "../_lifecycle.ts";
34
import { injectScript, isBrowser, removeScript } from "../_loader.ts";
@@ -50,6 +51,7 @@ export interface IntercomLoadOptions extends LoadOptions {
5051

5152
export async function load(options: IntercomLoadOptions): Promise<void> {
5253
if (!isBrowser()) return;
54+
if (options.consent === false) return;
5355
const configHash = hashConfig({ appId: options.appId });
5456
if (lifecycle.state() === "ready" && lifecycle.configHash() === configHash) return;
5557
if (lifecycle.state() === "loading") return;
@@ -60,6 +62,7 @@ export async function load(options: IntercomLoadOptions): Promise<void> {
6062
lifecycle.transition("loading");
6163
currentAppId = options.appId;
6264
lifecycle.setConfigHash(configHash);
65+
await waitForDefer(options.defer ?? "immediate");
6366
w().intercomSettings = { app_id: options.appId };
6467
const stub = ensureStub();
6568
stub("boot", { app_id: options.appId });

0 commit comments

Comments
 (0)