fence-attrs.ts1.6 KBView on GitHub
/**
 * Shared attribute handling for the coaching fence nodes.
 *
 * Both nodes carry their verbatim fence body in a percent-encoded HTML attribute and render
 * agent-authored links, so both need the same two guards. See apps/server/docs/coaching-agent.md
 * §3.2 part E.
 */

/**
 * Decode a fence body out of its `data-*` attribute.
 *
 * `decodeURIComponent` throws a `URIError` on a malformed sequence (a lone `%`), and this runs
 * inside ProseMirror's DOM parse — an exception there takes down the whole editor rather than the
 * one node. Everything else in these fences degrades to a raw render, so this does too.
 */
export function decodeFenceAttr(raw: string | null): string {
  if (!raw) return '';
  try {
    return decodeURIComponent(raw);
  } catch {
    return raw;
  }
}

/** Schemes safe to put in an `href`. `javascript:` and `data:` execute; nothing else here does. */
const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:']);

/**
 * Pass through a link only when its scheme is safe, otherwise drop it.
 *
 * The fence body is document content written by an agent, so a `recording:` value is untrusted
 * input — a `javascript:` URL there would execute on click.
 */
export function safeHref(raw: string | undefined): string | undefined {
  if (!raw) return undefined;
  const trimmed = raw.trim();
  try {
    // A relative URL resolves against the base and inherits its scheme, so it stays allowed.
    const { protocol } = new URL(trimmed, 'https://cedar.invalid');
    return SAFE_SCHEMES.has(protocol) ? trimmed : undefined;
  } catch {
    return undefined;
  }
}