packages/contracts/extension-bridge.ts
Rama main · réplica de lectura del repositorio original.
/*
* The «На Полку» browser extension and Полка's own page talk through
* window.postMessage (extensions/chrome/README.md, «Мост со страницей Полки»).
* This module is the whole protocol: both sides import it, so the shapes and
* the checks cannot drift apart. It has no DOM or chrome.* dependencies and is
* unit-tested in tests/extension-bridge.test.ts.
*
* Rules on both sides:
* - only messages from the same window (event.source === window), never from a
* frame or another tab;
* - only from the expected origin: the page accepts its own origin, the
* extension's bridge accepts only the Полка address it is configured for;
* - a nonce chosen by the page in «hello» is echoed in every later message;
* a message with another nonce is ignored;
* - an import carries one artifact URL from a short allowlist and nothing else.
*/
export const BRIDGE_VERSION = 1;
export const PAGE_SOURCE = "polka-page";
export const EXTENSION_SOURCE = "polka-extension";
/** 128+ bits, base64url; page-generated for each mount. */
export const NONCE = /^[A-Za-z0-9_-]{22,64}$/;
const REQUEST_ID = /^[A-Za-z0-9_-]{8,64}$/;
const MAX_URL = 2048;
const MAX_TEXT = 500;
export type ArtifactProvider = "claude" | "chatgpt";
export type PageMessage =
| { source: typeof PAGE_SOURCE; v: 1; type: "hello"; nonce: string }
| {
source: typeof PAGE_SOURCE;
v: 1;
type: "import";
nonce: string;
requestId: string;
url: string;
};
export type ImportStage = "opening" | "extracting" | "saving" | "connecting";
export type ImportSuccess = {
ok: true;
title: string;
/** Share link, or null when saved privately (reason in note). */
url: string | null;
shelfUrl: string;
note: string | null;
};
export type ImportFailure = {
ok: false;
code: "not_connected" | "unsupported_url" | "not_found" | "extract_failed" | "publish_failed" | "busy" | "timeout";
message: string;
};
export type ExtensionMessage =
| {
source: typeof EXTENSION_SOURCE;
v: 1;
type: "ready";
nonce: string;
version: string;
connected: boolean;
}
| {
source: typeof EXTENSION_SOURCE;
v: 1;
type: "progress";
nonce: string;
requestId: string;
stage: ImportStage;
}
| {
source: typeof EXTENSION_SOURCE;
v: 1;
type: "result";
nonce: string;
requestId: string;
result: ImportSuccess | ImportFailure;
};
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const text = (value: unknown, max = MAX_TEXT) => typeof value === "string" && value.length <= max;
const nullableText = (value: unknown, max = MAX_TEXT) => value === null || text(value, max);
const webUrl = (value: unknown) => {
if (typeof value !== "string" || value.length > MAX_URL) return false;
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
};
/**
* The artifact links the extension will open: a Claude artifact (chat,
* published, shared or Claude Code) or a ChatGPT shared canvas/conversation.
* Returns a normalised URL (no credentials, no fragment) or null.
*/
export function importableArtifact(value: unknown): { provider: ArtifactProvider; url: string } | null {
if (typeof value !== "string" || value.length > MAX_URL) return null;
let url: URL;
try {
url = new URL(value.trim());
} catch {
return null;
}
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
url.hash = "";
const host = url.hostname.toLowerCase();
const path = url.pathname;
const id = "[A-Za-z0-9-]{6,80}";
if (
host === "claude.ai" &&
new RegExp(`^/(artifact/${id}|public/artifacts/${id}|code/artifact/${id}|chat/${id})/?$`).test(path)
)
return { provider: "claude", url: url.href };
if (host === "chatgpt.com" && new RegExp(`^/(canvas/shared/${id}|share/${id}|c/${id})/?$`).test(path))
return { provider: "chatgpt", url: url.href };
return null;
}
export function parsePageMessage(data: unknown): PageMessage | null {
if (!isObject(data) || data.source !== PAGE_SOURCE || data.v !== 1) return null;
if (typeof data.nonce !== "string" || !NONCE.test(data.nonce)) return null;
if (data.type === "hello") return { source: PAGE_SOURCE, v: 1, type: "hello", nonce: data.nonce };
if (data.type === "import" && typeof data.requestId === "string" && REQUEST_ID.test(data.requestId)) {
const artifact = importableArtifact(data.url);
if (!artifact) return null;
return {
source: PAGE_SOURCE,
v: 1,
type: "import",
nonce: data.nonce,
requestId: data.requestId,
url: artifact.url,
};
}
return null;
}
const STAGES: readonly ImportStage[] = ["opening", "extracting", "saving", "connecting"];
const FAILURES: readonly ImportFailure["code"][] = [
"not_connected",
"unsupported_url",
"not_found",
"extract_failed",
"publish_failed",
"busy",
"timeout",
];
function parseResult(value: unknown): ImportSuccess | ImportFailure | null {
if (!isObject(value)) return null;
if (value.ok === true) {
if (
!text(value.title, 200) ||
!(value.url === null || webUrl(value.url)) ||
!webUrl(value.shelfUrl) ||
!nullableText(value.note)
)
return null;
return {
ok: true,
title: value.title as string,
url: value.url as string | null,
shelfUrl: value.shelfUrl as string,
note: value.note as string | null,
};
}
if (value.ok === false && FAILURES.includes(value.code as ImportFailure["code"]) && text(value.message))
return {
ok: false,
code: value.code as ImportFailure["code"],
message: value.message as string,
};
return null;
}
export function parseExtensionMessage(data: unknown): ExtensionMessage | null {
if (!isObject(data) || data.source !== EXTENSION_SOURCE || data.v !== 1) return null;
if (typeof data.nonce !== "string" || !NONCE.test(data.nonce)) return null;
const base = { source: EXTENSION_SOURCE, v: 1, nonce: data.nonce } as const;
if (data.type === "ready" && text(data.version, 32))
return {
...base,
type: "ready",
version: data.version as string,
connected: data.connected === true,
};
if (typeof data.requestId !== "string" || !REQUEST_ID.test(data.requestId)) return null;
if (data.type === "progress" && STAGES.includes(data.stage as ImportStage))
return {
...base,
type: "progress",
requestId: data.requestId,
stage: data.stage as ImportStage,
};
if (data.type === "result") {
const result = parseResult(data.result);
return result ? { ...base, type: "result", requestId: data.requestId, result } : null;
}
return null;
}
/** The parts of a MessageEvent both sides check. */
export type BridgeEvent = { origin: string; source: unknown; data: unknown };
/**
* One check for a received event: from this very window, from the expected
* origin, well-formed, and carrying the expected nonce (when one is set).
*/
export function acceptPageEvent(
event: BridgeEvent,
expected: { window: unknown; origin: string; nonce: string | null },
): PageMessage | null {
if (event.source !== expected.window || event.origin !== expected.origin) return null;
const message = parsePageMessage(event.data);
if (!message) return null;
// «hello» sets the nonce; everything after it must carry the same one.
if (message.type !== "hello" && message.nonce !== expected.nonce) return null;
return message;
}
export function acceptExtensionEvent(
event: BridgeEvent,
expected: { window: unknown; origin: string; nonce: string },
): ExtensionMessage | null {
if (event.source !== expected.window || event.origin !== expected.origin) return null;
const message = parseExtensionMessage(event.data);
return message && message.nonce === expected.nonce ? message : null;
}