mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
153 lines
5.8 KiB
TypeScript
153 lines
5.8 KiB
TypeScript
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<typeof panelActionRequestSchema>
|
|
|
|
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<typeof panelActionCallSchema>
|
|
|
|
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
|
|
}
|