import { z } from 'zod' import { isPluginPanelAction } from './plugin-host-api' /** * postMessage protocol between a sandboxed plugin panel iframe and the host * renderer, plus the action call shape relayed to main. The frame has an * opaque origin (sandbox="allow-scripts"), so neither side can use origins * for trust: the host verifies the sending window's identity and re-validates * every payload here; main re-checks capabilities before executing. * * Param/result schemas come from the host API v0 spec table — the panel * bridge is a transport, not a second contract. */ export const PANEL_ACTION_REQUEST_TYPE = 'orca-panel-action' export const PANEL_ACTION_RESULT_TYPE = 'orca-panel-action-result' export const PANEL_PING_TYPE = 'orca-panel-ping' export const PANEL_PONG_TYPE = 'orca-panel-pong' export const PLUGIN_PANEL_FRAME_NAME_PREFIX = 'orca-plugin-panel:' /** Per-plugin bridge budgets, enforced host-side. */ export const PANEL_MESSAGE_MAX_BYTES = 64 * 1024 export const PANEL_MESSAGE_RATE_LIMIT = { maxMessages: 30, perMs: 10_000 } /** Size cap for the reserved liveness lane. Deliberately size-only: any * per-window count on this lane can be spent by the panel's own pongs and * would drop the next genuine reply, which is the starvation this reserved * lane exists to prevent. Aggregate cost stays bounded because pongs are also * charged to the data budget and cost O(1) plus a walk capped here. */ export const PANEL_CONTROL_MESSAGE_MAX_BYTES = 1024 /** Watchdog cadence: a panel that misses a pong deadline is demoted to an * errored badge. Busy-loop detection is valid only while the runtime frame- * process gate confirms the sandbox stays outside the host renderer. */ export const PANEL_WATCHDOG_PING_INTERVAL_MS = 10_000 export const PANEL_WATCHDOG_PONG_TIMEOUT_MS = 5_000 export const panelActionRequestSchema = z.object({ type: z.literal(PANEL_ACTION_REQUEST_TYPE), /** Plugin-chosen correlation id echoed back on the result message. */ requestId: z.string().min(1).max(128), action: z.string().min(1).refine(isPluginPanelAction, 'not a panel-callable action'), params: z.unknown().optional() }) export type PluginPanelActionRequest = z.infer export const panelPongSchema = z.object({ type: z.literal(PANEL_PONG_TYPE), pingId: z.number().int().nonnegative() }) export type PluginPanelActionErrorCode = | 'invalid_request' | 'unknown_method' | 'capability_denied' | 'consent_required' | 'panel_forbidden' | 'invalid_params' | 'rate_limited' | 'unavailable' | 'action_failed' /** Result message posted back into the panel iframe. */ export type PluginPanelActionResultMessage = { type: typeof PANEL_ACTION_RESULT_TYPE requestId: string ok: boolean value?: unknown errorCode?: PluginPanelActionErrorCode error?: string } /** Outcome of executing a panel action in main (wire shape of * `plugins:panelAction` / `plugins.panelAction`). */ export type PluginPanelActionOutcome = | { ok: true; value: unknown } | { ok: false; code: PluginPanelActionErrorCode; error: string } export const panelSessionTokenSchema = z.string().min(32).max(128) /** Call shape relayed by a trusted panel host. The opaque session is issued * while loading one approved panel, so the caller never supplies identity. */ export const panelActionCallSchema = z .object({ sessionToken: panelSessionTokenSchema, action: z.string().min(1), params: z.unknown().optional() }) .strict() export type PluginPanelActionCall = z.infer export type PluginPanelEntry = { html: string sessionToken: string } export type PanelActionRequestParseResult = | { ok: true; request: PluginPanelActionRequest } | { ok: false; requestId: string | null; error: string } /** Validates a raw `message` event payload from the panel iframe. On failure * still surfaces a best-effort requestId so the host can answer with an * error instead of silently dropping the request. */ export function parsePanelActionRequest(data: unknown): PanelActionRequestParseResult { const parsed = panelActionRequestSchema.safeParse(data) if (parsed.success) { return { ok: true, request: parsed.data } } let requestId: string | null = null if (typeof data === 'object' && data !== null && 'requestId' in data) { const raw = (data as { requestId?: unknown }).requestId if (typeof raw === 'string' && raw.length > 0 && raw.length <= 128) { requestId = raw } } const issue = parsed.error.issues[0] const path = issue?.path.join('.') || '(root)' return { ok: false, requestId, error: `${path}: ${issue?.message ?? 'invalid panel action request'}` } } /** True when `data` even looks like a bridge request (right `type`). Used to * ignore unrelated window messages without replying to them. */ export function looksLikePanelActionRequest(data: unknown): boolean { return ( typeof data === 'object' && data !== null && (data as { type?: unknown }).type === PANEL_ACTION_REQUEST_TYPE ) } /** Reads a valid pong's pingId, or null. Hand-rolled rather than * `panelPongSchema.safeParse` because a rejected parse allocates an issue * list, which is ~90x the accepted-path cost — free CPU for a panel spamming * near-miss pongs. The schema stays the contract; this mirrors it exactly. */ export function readPanelPongId(data: unknown): number | null { if (typeof data !== 'object' || data === null) { return null } const frame = data as { type?: unknown; pingId?: unknown } if (frame.type !== PANEL_PONG_TYPE || typeof frame.pingId !== 'number') { return null } // isSafeInteger, not isInteger: zod's .int() rejects 2**53 and above, and a // wider reader would admit ids the watchdog can never have issued. return Number.isSafeInteger(frame.pingId) && frame.pingId >= 0 ? frame.pingId : null }