Ctrl K
ComunidadGuías…

packages/contracts/bookmarklet.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
 * The «На Полку» bookmarklet (extensions/bookmarklet) and Полка's
 * /bring/receive page talk through window.postMessage between two tabs: the
 * AI chat the user clicked the bookmark on, and the Полка tab the bookmark
 * opened from it. This module is the whole protocol; both sides import it.
 * No DOM, no zod: it ships inside the bookmarklet itself.
 * Unit-tested in tests/bookmarklet.test.ts.
 *
 * 1. On click the bookmark reads the artifact while its page is still in the
 *    foreground (menus and animations stop in a background tab), then opens
 *    <Полка>/bring/receive#nonce=<random> with the click's user activation and
 *    keeps the window handle. If the activation has run out, its toast offers
 *    a button that opens the tab.
 * 2. It posts one BookmarkletMessage (the source, or why there is none) to
 *    that window with targetOrigin = the Полка origin baked into the bookmark,
 *    again every half second until the tab replies (it may still be loading),
 *    for at most 15 s.
 * 3. The Полка tab accepts the first message that is from window.opener,
 *    from an AI provider's https origin in SOURCE_HOSTS, carries the nonce of
 *    its own URL fragment, describes a page of that same origin and is at most
 *    MAX_BYTES; it replies «ready» (or «rejected») to the opener and ignores
 *    everything after.
 *
 * Nothing else leaves the provider's page: no fetch, no XHR, no cookies.
 */
import { MAX_BYTES, MAX_TITLE } from "./constants.ts";

export const BOOKMARKLET_MESSAGE = "polka.bookmarklet.v1";
/** Where the bookmark opens Полка; the nonce follows in the fragment. */
export const RECEIVE_PATH = "/bring/receive";
/** How long the bookmark keeps offering the data to the Полка tab. */
export const DELIVERY_MS = 15_000;
/** The same limit as every other upload. */
export const BOOKMARKLET_MAX_BYTES = MAX_BYTES;
/** 144 bits, base64url. */
export const BOOKMARKLET_NONCE = /^[A-Za-z0-9_-]{24,64}$/;
const MAX_URL = 2048;

/**
 * The AI chats a bookmark may send from (https only, exact hosts). Claude and
 * ChatGPT get dedicated readers; on the others the bookmark sends a snapshot
 * of the page. Anything else: the bookmark says so and opens nothing.
 */
export const SOURCE_HOSTS = [
  "claude.ai",
  "chatgpt.com",
  "chat.openai.com",
  "gemini.google.com",
  "aistudio.google.com",
  "www.perplexity.ai",
  "perplexity.ai",
  "v0.app",
  "v0.dev",
  "chat.deepseek.com",
  "chat.mistral.ai",
  "copilot.microsoft.com",
  "grok.com",
  "alice.yandex.ru",
  "giga.chat",
] as const;

/** artifact: read by a provider reader; snapshot: the page as rendered, without scripts. */
export type BookmarkletKind = "artifact" | "snapshot";
/** html: save as a page; jsx/tsx: component source; text: anything else. */
export type BookmarkletLanguage = "html" | "jsx" | "tsx" | "text";

export type BookmarkletSource = {
  /** The page the bookmark was clicked on. */
  url: string;
  title: string;
  kind: BookmarkletKind;
  language: BookmarkletLanguage;
  text: string;
};

/** Why the bookmark had nothing to send; the Полка tab explains it in its own words. */
export type BookmarkletFailure = "not_found" | "sign_in" | "too_large";

export type BookmarkletMessage =
  | { type: typeof BOOKMARKLET_MESSAGE; nonce: string; source: BookmarkletSource }
  | { type: typeof BOOKMARKLET_MESSAGE; nonce: string; failure: BookmarkletFailure };

export type BookmarkletRejection = "too_large" | "invalid";

export type BookmarkletReply =
  | { type: typeof BOOKMARKLET_MESSAGE; nonce: string; reply: "ready" }
  | {
      type: typeof BOOKMARKLET_MESSAGE;
      nonce: string;
      reply: "rejected";
      reason: BookmarkletRejection;
    };

const FAILURES: readonly BookmarkletFailure[] = ["not_found", "sign_in", "too_large"];
const KINDS: readonly BookmarkletKind[] = ["artifact", "snapshot"];
const LANGUAGES: readonly BookmarkletLanguage[] = ["html", "jsx", "tsx", "text"];

const isObject = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

/** An https origin of one of SOURCE_HOSTS, nothing else (no port, no http). */
export function allowedSourceOrigin(origin: unknown): boolean {
  if (typeof origin !== "string") return false;
  let url: URL;
  try {
    url = new URL(origin);
  } catch {
    return false;
  }
  return (
    url.protocol === "https:" &&
    url.origin === origin &&
    !url.port &&
    (SOURCE_HOSTS as readonly string[]).includes(url.hostname)
  );
}

/** The nonce from /bring/receive's fragment (#nonce=…), or null. */
export function nonceFromHash(hash: string): string | null {
  const value = new URLSearchParams(hash.replace(/^#/, "")).get("nonce");
  return value && BOOKMARKLET_NONCE.test(value) ? value : null;
}

/** UTF-8 length without TextEncoder allocations of the whole text. */
export function utf8Bytes(text: string): number {
  let bytes = 0;
  for (let index = 0; index < text.length; index++) {
    const code = text.charCodeAt(index);
    if (code < 0x80) bytes += 1;
    else if (code < 0x800) bytes += 2;
    else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
      bytes += 4;
      index++;
    } else bytes += 3;
  }
  return bytes;
}

/**
 * The page's address as provenance keeps it: https, no credentials, no query
 * and no fragment (they may carry tokens). Null when nothing is left.
 */
export function provenanceUrl(value: string): string | null {
  try {
    const url = new URL(value);
    if (url.protocol !== "https:" || url.username || url.password) return null;
    url.search = "";
    url.hash = "";
    return url.href.length <= MAX_URL ? url.href : null;
  } catch {
    return null;
  }
}

/** The parts of a MessageEvent the receiving tab checks. */
export type BookmarkletEvent = { origin: string; source: unknown; data: unknown };

export type Accepted =
  | { status: "source"; source: BookmarkletSource }
  | { status: "failure"; failure: BookmarkletFailure }
  | { status: "rejected"; reason: BookmarkletRejection };

/**
 * One check for a message arriving at /bring/receive. Null: not for us, say
 * nothing (another window, another origin, another nonce, not this protocol).
 * «failure»: the bookmark found nothing to send and says why. «rejected»: it
 * is our bookmark, but the content cannot be taken; the page answers
 * «rejected» so the bookmark can tell the user.
 */
export function acceptBookmarkletEvent(
  event: BookmarkletEvent,
  expected: { opener: unknown; nonce: string | null },
): Accepted | null {
  if (!expected.nonce || !expected.opener || event.source !== expected.opener) return null;
  if (!allowedSourceOrigin(event.origin)) return null;
  const data = event.data;
  if (!isObject(data) || data.type !== BOOKMARKLET_MESSAGE || data.nonce !== expected.nonce) return null;
  if ("failure" in data)
    return FAILURES.includes(data.failure as BookmarkletFailure)
      ? { status: "failure", failure: data.failure as BookmarkletFailure }
      : { status: "rejected", reason: "invalid" };
  return checkSource(data.source, event.origin);
}

/**
 * A source as sent by the bookmark on the page at `origin` (also one restored
 * after sign-in): well-formed, naming a page of that origin, non-empty and
 * within MAX_BYTES.
 */
export function checkSource(source: unknown, sender: string): Accepted {
  if (!isObject(source) || !allowedSourceOrigin(sender)) return { status: "rejected", reason: "invalid" };
  const { url, title, kind, language, text } = source;
  if (
    typeof url !== "string" ||
    url.length > MAX_URL ||
    typeof title !== "string" ||
    typeof text !== "string" ||
    !KINDS.includes(kind as BookmarkletKind) ||
    !LANGUAGES.includes(language as BookmarkletLanguage)
  )
    return { status: "rejected", reason: "invalid" };
  // The page it names is the page that sent it.
  let origin: string;
  try {
    origin = new URL(url).origin;
  } catch {
    return { status: "rejected", reason: "invalid" };
  }
  if (origin !== sender) return { status: "rejected", reason: "invalid" };
  // A cheap bound first: a UTF-16 unit is at least one UTF-8 byte.
  if (text.length > BOOKMARKLET_MAX_BYTES || utf8Bytes(text) > BOOKMARKLET_MAX_BYTES)
    return { status: "rejected", reason: "too_large" };
  if (!text.trim()) return { status: "rejected", reason: "invalid" };
  return {
    status: "source",
    source: {
      url,
      title: title
        .replace(/[\x00-\x1f\x7f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, "")
        .replace(/\s+/g, " ")
        .trim()
        .slice(0, MAX_TITLE),
      kind: kind as BookmarkletKind,
      language: language as BookmarkletLanguage,
      text,
    },
  };
}

/** The bookmark's side: a reply from its own Полка tab, or null. */
export function parseBookmarkletReply(
  event: BookmarkletEvent,
  expected: { tab: unknown; origin: string; nonce: string },
): BookmarkletReply | null {
  if (event.source !== expected.tab || event.origin !== expected.origin) return null;
  const data = event.data;
  if (!isObject(data) || data.type !== BOOKMARKLET_MESSAGE || data.nonce !== expected.nonce) return null;
  if (data.reply === "ready") return { type: BOOKMARKLET_MESSAGE, nonce: expected.nonce, reply: "ready" };
  if (data.reply === "rejected" && (data.reason === "too_large" || data.reason === "invalid"))
    return {
      type: BOOKMARKLET_MESSAGE,
      nonce: expected.nonce,
      reply: "rejected",
      reason: data.reason,
    };
  return null;
}