import { createHttpCapabilities } from "../capabilities/http-client"; import type { ColineActorRef } from "../capabilities/types"; import type { ColineAppV2, ColineFileRenderContextV2, ColineHomeRenderContextV2, ColineScheduleContextV2, ColineToolContext, } from "../define/types"; import { validateChatCardTree } from "../tree/chat-card"; import { ColineProtocolError, parseSignedProtocolRequest, type ColineProtocolEnvelope, } from "./protocol"; import { RENDER_SURFACE_KIND, renderSurfacePayloadSchema, } from "./render-surface"; import { TOOL_INVOKE_KIND, toolInvokePayloadSchema, } from "./tool-invoke"; import { TRIGGER_SCHEDULE_KIND, triggerSchedulePayloadSchema, } from "./trigger-schedule"; /** * The external app server handler (plan §10.3, §9.3): ONE handler serves * every Coline → app-backend request kind. Mount it at * `POST {baseUrl}/coline/app`. * * ```ts * // app/coline/app/route.ts (Next.js) — or any fetch-shaped server * import app from "../../../app.config"; * import { createColineAppHandler } from "@colineapp/sdk/v2"; * * export const POST = createColineAppHandler({ * app, * secret: process.env.COLINE_DELIVERY_SECRET!, * }); * ``` * * Kinds handled: * - `tool.invoke` → the tool's `execute` with a capability-bound context * - `render.surface` → the file type's tree render handlers, or `renderHome` * * Render contexts carry a read-ceiling invocation token: a render handler * that attempts a mutation fails at Coline's capability gate, not here. */ interface WireError { error: { code: string; message: string }; } function json(body: unknown, status: number): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } function wireError(code: string, message: string, status: number): Response { const body: WireError = { error: { code, message } }; return json(body, status); } function actorFromEnvelope(envelope: ColineProtocolEnvelope): ColineActorRef { return envelope.actor.kind === "user" ? { kind: "user", userId: envelope.actor.userId } : { kind: "app" }; } async function handleToolInvoke( app: ColineAppV2, envelope: ColineProtocolEnvelope, ): Promise { const payload = toolInvokePayloadSchema.safeParse(envelope.payload); if (!payload.success) { return wireError("invalid_payload", "tool.invoke payload is invalid.", 400); } const tool = app.runtime.tools.get(payload.data.tool); if (!tool) { return wireError( "unknown_tool", `App "${app.manifest.key}" does not define a tool named "${payload.data.tool}".`, 404, ); } const input = tool.input.safeParse(payload.data.input); if (!input.success) { return wireError( "invalid_input", `Tool input is invalid: ${input.error.issues .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("; ")}`, 400, ); } const context: ColineToolContext = { coline: createHttpCapabilities(payload.data.capabilities), workspace: { workspaceId: envelope.workspace.workspaceId, workspaceSlug: envelope.workspace.workspaceSlug, workspaceName: envelope.workspace.workspaceSlug, }, actor: actorFromEnvelope(envelope), app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, }; try { const result = await tool.execute(input.data as never, context); if (result.card) { const validation = validateChatCardTree(result.card); if (!validation.ok) { return wireError("invalid_card", `Tool result card rejected: ${validation.reason}`, 400); } } return json( { result: { ...(result.output !== undefined ? { output: result.output } : {}), ...(result.card ? { card: result.card } : {}), }, }, 200, ); } catch (error) { return wireError( "tool_execution_failed", error instanceof Error ? error.message : String(error), 500, ); } } async function handleRenderSurface( app: ColineAppV2, envelope: ColineProtocolEnvelope, ): Promise { const payload = renderSurfacePayloadSchema.safeParse(envelope.payload); if (!payload.success) { return wireError("invalid_payload", "render.surface payload is invalid.", 400); } const coline = createHttpCapabilities(payload.data.capabilities); const base = { coline, workspace: { workspaceId: envelope.workspace.workspaceId, workspaceSlug: envelope.workspace.workspaceSlug, workspaceName: envelope.workspace.workspaceSlug, }, actor: envelope.actor.kind === "user" ? actorFromEnvelope(envelope) : null, app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, }; try { if (payload.data.surface === "home") { if (!app.runtime.renderHome) { return wireError("unknown_surface", "This app has no tree home surface.", 404); } const context: ColineHomeRenderContextV2 = base; const tree = await app.runtime.renderHome(context); return json({ result: { tree } }, 200); } const file = payload.data.file; if (!file) { return wireError("invalid_payload", "File surfaces require a file reference.", 400); } const runtime = app.runtime.fileTypes.get(file.typeKey); const handler = payload.data.surface === "editor" ? runtime?.renderEditor : payload.data.surface === "preview" ? runtime?.renderPreview : runtime?.renderInline; if (!handler) { return wireError( "unknown_surface", `File type "${file.typeKey}" has no ${payload.data.surface} handler.`, 404, ); } const context: ColineFileRenderContextV2 = { ...base, file, document: payload.data.document ?? {}, }; const tree = await handler(context); if (payload.data.surface === "inline") { const validation = validateChatCardTree(tree); if (!validation.ok) { return wireError("invalid_tree", `Inline surface rejected: ${validation.reason}`, 400); } } return json({ result: { tree } }, 200); } catch (error) { return wireError( "render_failed", error instanceof Error ? error.message : String(error), 500, ); } } async function handleTriggerSchedule( app: ColineAppV2, envelope: ColineProtocolEnvelope, ): Promise { const payload = triggerSchedulePayloadSchema.safeParse(envelope.payload); if (!payload.success) { return wireError("invalid_payload", "trigger.schedule payload is invalid.", 400); } if (!app.runtime.onSchedule) { return wireError("unknown_schedule", "This app has no schedule handler.", 404); } const context: ColineScheduleContextV2 = { coline: createHttpCapabilities(payload.data.capabilities), workspace: { workspaceId: envelope.workspace.workspaceId, workspaceSlug: envelope.workspace.workspaceSlug, workspaceName: envelope.workspace.workspaceSlug, }, app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, scheduleId: payload.data.scheduleId, firedAt: payload.data.firedAt, }; try { await app.runtime.onSchedule(context); return json({ result: { ok: true } }, 200); } catch (error) { return wireError( "schedule_failed", error instanceof Error ? error.message : String(error), 500, ); } } export function createColineAppHandler(params: { app: ColineAppV2; secret: string; }): (request: Request) => Promise { return async (request) => { let envelope: ColineProtocolEnvelope; try { const parsed = await parseSignedProtocolRequest({ request, secret: params.secret, }); envelope = parsed.envelope; } catch (error) { if (error instanceof ColineProtocolError) { return wireError(error.code, error.message, 401); } throw error; } switch (envelope.kind) { case TOOL_INVOKE_KIND: return handleToolInvoke(params.app, envelope); case RENDER_SURFACE_KIND: return handleRenderSurface(params.app, envelope); case TRIGGER_SCHEDULE_KIND: return handleTriggerSchedule(params.app, envelope); default: return wireError( "unsupported_kind", `This endpoint handles "${TOOL_INVOKE_KIND}", "${RENDER_SURFACE_KIND}", and "${TRIGGER_SCHEDULE_KIND}"; received "${envelope.kind}".`, 400, ); } }; }