Skip to content

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 →