/***************************************************************
RFC-1178: defines the `werkstatt` named Trusted Types policy — the
allowlist anchor for `trusted-types werkstatt` and the only sanctioned
channel for minting trusted values once `require-trusted-types-for
'script'` is emitted. Policy creation runs at module eval so it precedes
every sink write the orchestrator dispatches; this module is statically
imported by orchestrator.ts and MUST never move behind a dynamic import
or a lazy feature initializer.
- Never create a `default` policy — a default sanitizes arbitrary strings silently and defeats the audit; `default` never appears in the trusted-types allowlist (RFC-1178).
- No `@types/trusted-types` dependency — a minimal local declaration suffices for the policy surface.
- Does not sanitize HTML — creators accept only platform-branded inputs minted by ttHTML/ttScriptURL; unbranded input throws.
- RFC-1178: initial implementation — werkstatt named policy with provenance-branded createHTML/createScriptURL creators and a feature-detected no-op fallback.
***************************************************************/
// Minimal Trusted Types surface — per RFC-1178 no @types/trusted-types dep.
interface WerkstattTrustedTypePolicy {
createHTML(input: unknown): unknown;
createScriptURL(input: unknown): unknown;
}
interface WerkstattTrustedTypesFactory {
createPolicy(
name: string,
rules: {
createHTML?(input: unknown): string;
createScriptURL?(input: unknown): string;
},
): WerkstattTrustedTypePolicy;
}
const tt = (globalThis as { trustedTypes?: WerkstattTrustedTypesFactory }).trustedTypes;
/**
* Provenance registry — only values minted by {@link ttHTML}/{@link ttScriptURL}
* inside this module can pass the creators; arbitrary strings throw. This is
* what keeps the `werkstatt` policy from degenerating into a pass-through.
*
* The registry is a side channel because `TrustedTypePolicy.create*(input)`
* declares `input` as DOMString in WebIDL — the browser stringifies the
* argument before invoking our callback, so an object brand would arrive as
* "[object Object]" and can never survive the call. Entries are single-use:
* the creator deletes the minted value while unwrapping.
*/
const mintedHTML = new Map();
const mintedScriptURL = new Map();
function mint(value: T, registry: Map): T {
registry.set(value, (registry.get(value) ?? 0) + 1);
return value;
}
function unbranded(input: unknown, registry: Map): T {
if (typeof input === "string") {
const count = registry.get(input) ?? 0;
if (count > 1) registry.set(input, count - 1);
else if (count === 1) registry.delete(input);
if (count > 0) return input as T;
}
throw new TypeError(
"werkstatt Trusted Types policy: refusing unbranded input — mint it via ttHTML()/ttScriptURL() so the call site is auditable.",
);
}
/**
* The `werkstatt` policy. `undefined` where Trusted Types are unsupported —
* the CSP `trusted-types werkstatt` allowlist entry is then inert, matching
* the feature-detect contract.
*/
function createWerkstattPolicy(): WerkstattTrustedTypePolicy | undefined {
try {
return tt?.createPolicy("werkstatt", {
createHTML: (input) => unbranded(input, mintedHTML),
createScriptURL: (input) => unbranded(input, mintedScriptURL),
});
} catch (err) {
// A `trusted-types` CSP directive that does not name `werkstatt` makes
// createPolicy throw — degrade to no-policy instead of killing the
// orchestrator chunk at eval; sinks then fail closed at the write site.
console.warn("[werkstatt] trusted-types policy creation failed:", err);
return undefined;
}
}
export const werkstattPolicy: WerkstattTrustedTypePolicy | undefined = createWerkstattPolicy();
/**
* Mint a trusted HTML value for a sanctioned sink. At ship time no call site
* uses these wrappers — they are the enforced extension point: a future
* first-party `innerHTML` writer must route through `ttHTML` so every trusted
* value traces to an audited call site.
*/
export function ttHTML(html: string): unknown {
if (!werkstattPolicy) return html;
return werkstattPolicy.createHTML(mint(html, mintedHTML));
}
/** Mint a trusted script URL for a sanctioned sink. */
export function ttScriptURL(url: string): unknown {
if (!werkstattPolicy) return url;
return werkstattPolicy.createScriptURL(mint(url, mintedScriptURL));
}
/**
* Type-preserving mint for ambiguous sink arguments. Some third-party call
* sites pick the sink method at runtime (`el[cond ? "insertAdjacentElement" :
* "insertAdjacentHTML"](pos, arg)`) where `arg` is an Element on one path and
* an HTML string on the other — a plain ttHTML() wrap would stringify the
* Element into "[object HTML…]" and break both paths. Non-strings (including
* already-trusted values) pass through untouched.
*/
export function ttMaybeHTML(value: T): T | unknown {
if (typeof value !== "string") return value;
return ttHTML(value);
}