Permissions-Policy Builder — TypeScript source
Build a Permissions-Policy header interactively. Control which browser features (camera, microphone, geolocation, etc.) your site can use.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure Permissions-Policy header builder / parser. Zero deps.
//
// The Permissions-Policy header is a comma-separated list of directives:
// Permissions-Policy: geolocation=(self), camera=(), microphone=*, usb=(https://a.example)
// Each directive maps a browser feature to an allowlist. An empty allowlist
// `()` disables the feature outright; `*` allows it everywhere; `self` limits
// it to the page's own origin; anything else is a space-separated origin list.
//
// A policy is modeled here as a map of feature -> allowlist array:
// { geolocation: ['self'], camera: [], usb: ['https://a.example'] }
// - [] => camera=() (disabled)
// - ['*'] => microphone=* (every origin)
// - ['self'] => geolocation=(self)
// - [origins...] => usb=(https://a.example https://b.example)
// Features absent from the map are absent from the header (browser default).
export type PrivacyImpact = 'high' | 'medium' | 'low';
export interface FeatureInfo {
/** The directive token used in the header, e.g. `geolocation`. */
name: string;
description: string;
privacyImpact: PrivacyImpact;
/** What browsers do when the feature is absent from the policy. */
defaultBrowserBehavior: string;
}
/** feature name -> allowlist tokens. `[]` = disabled, `['*']` = all origins. */
export type FeatureMap = Record<string, string[]>;
export const FEATURES: FeatureInfo[] = [
// --- high privacy impact ---------------------------------------------------
{ name: 'camera', description: 'Access the device camera for photos / video calls.', privacyImpact: 'high', defaultBrowserBehavior: 'Same-origin only; prompts the user.' },
{ name: 'microphone', description: 'Capture audio from the device microphone.', privacyImpact: 'high', defaultBrowserBehavior: 'Same-origin only; prompts the user.' },
{ name: 'geolocation', description: 'Read the precise GPS location of the visitor.', privacyImpact: 'high', defaultBrowserBehavior: 'Same-origin only; prompts the user.' },
{ name: 'display-capture', description: 'Screen / window sharing via getDisplayMedia.', privacyImpact: 'high', defaultBrowserBehavior: 'Same-origin only; prompts the user.' },
{ name: 'idle-detection', description: 'Detects when the user is away from the device — reveals usage patterns.', privacyImpact: 'high', defaultBrowserBehavior: 'Disabled; prompts the user.' },
{ name: 'serial', description: 'Talk to serial devices (Arduinos, POS terminals) over a physical port.', privacyImpact: 'high', defaultBrowserBehavior: 'Disabled; prompts the user.' },
{ name: 'usb', description: 'WebUSB — direct access to connected USB devices.', privacyImpact: 'high', defaultBrowserBehavior: 'Disabled; prompts the user.' },
{ name: 'hid', description: 'Human Interface Devices — raw access to unusual keyboards, gamepads, sensors.', privacyImpact: 'high', defaultBrowserBehavior: 'Disabled; prompts the user.' },
{ name: 'xr-spatial-tracking', description: 'Tracks head / hand position in WebXR sessions.', privacyImpact: 'high', defaultBrowserBehavior: 'Same-origin only; prompts the user.' },
// --- medium privacy impact -------------------------------------------------
{ name: 'accelerometer', description: 'Device motion sensor — can fingerprint and infer behaviour.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'ambient-light-sensor', description: 'Reads ambient light level around the device.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'battery', description: 'Battery Status API — a classic fingerprinting vector.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'gyroscope', description: 'Device orientation sensor — fingerprinting and behaviour inference.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'magnetometer', description: "Compass readings — can leak details of the user's surroundings.", privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'keyboard-map', description: 'Reads the physical keyboard layout — a small but real fingerprint.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'gamepad', description: 'Enumerates connected controllers and their button state.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'midi', description: 'Web MIDI — access to attached music hardware.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only; prompts the user.' },
{ name: 'payment', description: 'Payment Request API — can expose stored payment handles.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'publickey-credentials-get', description: 'WebAuthn credential requests.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'screen-wake-lock', description: 'Keeps the screen awake — drains battery and signals intent.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'speaker-selection', description: 'Enumerates and switches audio output devices.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'web-share', description: 'Invokes the OS share sheet with chosen content.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'encrypted-media', description: 'DRM (EME) — playback identity can be correlated.', privacyImpact: 'medium', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'document-domain', description: 'Let frames relax the same-origin policy via document.domain.', privacyImpact: 'medium', defaultBrowserBehavior: 'Allowed in same-origin pages; deprecated.' },
// --- low privacy impact ----------------------------------------------------
{ name: 'autoplay', description: 'Autoplay media with/without sound — not a data leak, an annoyance knob.', privacyImpact: 'low', defaultBrowserBehavior: 'Muted autoplay allowed; audible blocked.' },
{ name: 'cross-origin-isolated', description: 'COOP/COEP isolation for SharedArrayBuffer — hardens the page.', privacyImpact: 'low', defaultBrowserBehavior: 'Not isolated.' },
{ name: 'fullscreen', description: 'Element.requestFullscreen().', privacyImpact: 'low', defaultBrowserBehavior: 'Same-origin only.' },
{ name: 'navigation-override', description: 'Lets a frame intercept its own top-level navigations.', privacyImpact: 'low', defaultBrowserBehavior: 'Disabled.' },
{ name: 'picture-in-picture', description: 'Floating always-on-top video window.', privacyImpact: 'low', defaultBrowserBehavior: 'Same-origin only.' },
];
const FEATURE_NAME_RE = /^[a-z][a-z0-9-]*$/;
const ORIGIN_RE = /^(https?:\/\/|https?:)[\w.-]+(:\d+)?$/i;
function formatAllowlist(tokens: string[]): string {
if (tokens.length === 1 && tokens[0] === '*') return '*';
return `(${tokens.join(' ')})`;
}
/** Assemble a Permissions-Policy header value from a feature map. */
export function buildPermissionsPolicy(features: FeatureMap): string {
return Object.keys(features)
.sort()
.map((name) => `${name}=${formatAllowlist(features[name])}`)
.join(', ');
}
/**
* Parse a Permissions-Policy header value back into a feature map.
* Accepts an optional `Permissions-Policy:` prefix. Returns null when the
* syntax is malformed (bad directive, missing allowlist, bad token).
*/
export function parsePermissionsPolicy(header: string): FeatureMap | null {
const cleaned = header.trim().replace(/^permissions-policy\s*:\s*/i, '');
if (!cleaned) return null;
const map: FeatureMap = {};
for (const rawDirective of cleaned.split(',')) {
const directive = rawDirective.trim();
if (!directive) return null;
const eq = directive.indexOf('=');
if (eq <= 0) return null;
const name = directive.slice(0, eq).trim();
const value = directive.slice(eq + 1).trim();
if (!FEATURE_NAME_RE.test(name)) return null;
if (value === '*' || value === 'self') {
map[name] = [value];
continue;
}
if (!value.startsWith('(') || !value.endsWith(')')) return null;
const inner = value.slice(1, -1).trim();
if (!inner) {
map[name] = [];
continue;
}
const tokens = inner.split(/\s+/).map((t) => t.replace(/^"(.*)"$/, '$1'));
for (const token of tokens) {
if (token !== 'self' && token !== '*' && !ORIGIN_RE.test(token)) return null;
}
map[name] = tokens;
}
return map;
}
/**
* Syntax-check a header string the same way parsePermissionsPolicy does, with
* per-directive messages (used by the import panel; the Go twin's
* ValidatePermissionsPolicy mirrors this function).
*/
export function validateHeaderSyntax(header: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
const cleaned = header.trim().replace(/^permissions-policy\s*:\s*/i, '');
if (!cleaned) return { valid: false, errors: ['Header is empty.'] };
cleaned.split(',').forEach((rawDirective, i) => {
const directive = rawDirective.trim();
const where = `Directive ${i + 1}`;
if (!directive) {
errors.push(`${where}: empty (stray comma?).`);
return;
}
const eq = directive.indexOf('=');
if (eq <= 0) {
errors.push(`${where}: expected \`feature=allowlist\`, got \`${directive}\`.`);
return;
}
const name = directive.slice(0, eq).trim();
const value = directive.slice(eq + 1).trim();
if (!FEATURE_NAME_RE.test(name)) {
errors.push(`${where}: \`${name}\` is not a valid feature name.`);
return;
}
if (value === '*' || value === 'self') return;
if (!value.startsWith('(') || !value.endsWith(')')) {
errors.push(`${where}: \`${name}\` allowlist must be \`*\`, \`self\`, or \`(...)\` — got \`${value}\`.`);
return;
}
const inner = value.slice(1, -1).trim();
if (!inner) return; // () = disabled, valid
for (const raw of inner.split(/\s+/)) {
const token = raw.replace(/^"(.*)"$/, '$1');
if (token !== 'self' && token !== '*' && !ORIGIN_RE.test(token)) {
errors.push(`${where}: \`${name}\` has an invalid allowlist token \`${raw}\`.`);
}
}
});
return { valid: errors.length === 0, errors };
}
/**
* Privacy score 0-100 for a policy: how much it locks down the catalog.
* Each feature is weighted by privacy impact (high 3, medium 2, low 1).
* Disabled `()` earns full credit, `self` or an origin list half, `*` or
* "absent from the policy" none (the browser default stays in force).
*/
export function privacyScore(features: FeatureMap): number {
const weight: Record<PrivacyImpact, number> = { high: 3, medium: 2, low: 1 };
let earned = 0;
let possible = 0;
for (const feature of FEATURES) {
const w = weight[feature.privacyImpact];
possible += w;
const allowlist = features[feature.name];
if (allowlist && allowlist.length === 0) earned += w; // () disabled
else if (allowlist && !(allowlist.length === 1 && allowlist[0] === '*')) earned += w / 2; // self / origins
}
return Math.round((earned / possible) * 100);
}
// --- builder-family editor model --------------------------------------------
//
// The structured editor state: an ordered list of directive cards, each a
// feature name plus its allowlist tokens. This shape (unlike FeatureMap) can
// hold DUPLICATE feature names while typing, which is exactly what
// validatePermissionsPolicy flags.
/** Features where `*` hands sensitive hardware to every origin. */
export const SENSITIVE_FEATURES = ['camera', 'geolocation', 'microphone'] as const;
/** One editor card: a feature name plus its allowlist tokens. */
export interface DirectiveRule {
feature: string; // directive token, e.g. `geolocation`; FEATURE_NAME_RE is the syntax gate
allowlist: string[]; // [] = disabled, ['*'] = everywhere, ['self'] = own origin, else origins
}
export interface PermissionsPolicyConfig {
directives: DirectiveRule[];
}
export type PermissionsPolicyIssueCode =
| 'duplicate-directive'
| 'sensitive-wildcard'
| 'unknown-directive';
export interface PermissionsPolicyIssue {
code: PermissionsPolicyIssueCode;
severity: 'warn' | 'info';
groupIndex?: number;
value?: string;
}
/** Look a feature name up in the catalog (undefined = unknown directive). */
export function findFeature(name: string): FeatureInfo | undefined {
return FEATURES.find((f) => f.name === name);
}
/** Editor entries -> FeatureMap. Later duplicates win, exactly as the assembled header collapses them. */
export function configToFeatureMap(config: PermissionsPolicyConfig): FeatureMap {
const map: FeatureMap = {};
for (const d of config.directives) {
const name = d.feature.trim();
if (name) map[name] = d.allowlist;
}
return map;
}
/** FeatureMap -> editor entries in canonical (name-sorted) order. */
export function featureMapToConfig(map: FeatureMap): PermissionsPolicyConfig {
return {
directives: Object.keys(map)
.sort()
.map((feature) => ({ feature, allowlist: map[feature] })),
};
}
/**
* Lint the editor config the way a reviewer would read the header:
* duplicate directives (warn - only the last one would survive the header),
* `*` on a sensitive feature (warn - camera/geolocation/microphone for every
* origin), and names outside the catalog (info - typo or newer feature;
* browsers ignore unknown directives).
*/
export function validatePermissionsPolicy(config: PermissionsPolicyConfig): PermissionsPolicyIssue[] {
const issues: PermissionsPolicyIssue[] = [];
const seen = new Set<string>();
config.directives.forEach((d, groupIndex) => {
const name = d.feature.trim();
if (!name) return;
if (seen.has(name)) {
issues.push({ code: 'duplicate-directive', severity: 'warn', groupIndex, value: name });
} else {
seen.add(name);
}
if (
d.allowlist.length === 1 &&
d.allowlist[0] === '*' &&
(SENSITIVE_FEATURES as readonly string[]).includes(name)
) {
issues.push({ code: 'sensitive-wildcard', severity: 'warn', groupIndex, value: name });
}
if (!findFeature(name)) {
issues.push({ code: 'unknown-directive', severity: 'info', groupIndex, value: name });
}
});
return issues;
}
// URL codecs for shareable state. Grammar per `d` param:
// <enc(feature)>|<enc(token),enc(token),...>
// Every component is encodeURIComponent'd BEFORE the ','/'|' delimiters are
// assembled, so delimiters can never appear inside a component after decoding.
// An absent tail means an empty allowlist (`()`).
const enc = (s: string) => encodeURIComponent(s);
const dec = (s: string): string => {
try {
return decodeURIComponent(s);
} catch {
return s; // malformed escape - keep verbatim rather than throw
}
};
export function toQuery(config: PermissionsPolicyConfig): string {
const p = new URLSearchParams();
for (const d of config.directives) {
const name = d.feature.trim();
if (!name) continue;
p.append('d', `${enc(name)}|${d.allowlist.map(enc).join(',')}`);
}
return p.toString();
}
export function fromQuery(params: URLSearchParams): PermissionsPolicyConfig | null {
const ds = params.getAll('d');
if (ds.length === 0) return null;
const directives: DirectiveRule[] = [];
for (const raw of ds) {
const bar = raw.indexOf('|');
const feature = dec(bar === -1 ? raw : raw.slice(0, bar)).trim();
if (!feature) continue;
const tail = bar === -1 ? '' : raw.slice(bar + 1);
directives.push({ feature, allowlist: tail === '' ? [] : tail.split(',').map(dec) });
}
return { directives };
}
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →