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"; import { ENDPOINT_REQUEST_KIND, JOB_RUN_KIND, TRIGGER_EVENT_KIND, endpointRequestPayloadSchema, jobRunPayloadSchema, toEndpointResponse, toJobRunResult, triggerEventPayloadSchema, } from "./kinds"; import { createJobRunInfo, parseEndpointJson } from "./runtime-helpers"; /** * 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, ); } } function workspaceFromEnvelope(envelope: ColineProtocolEnvelope) { return { workspaceId: envelope.workspace.workspaceId, workspaceSlug: envelope.workspace.workspaceSlug, workspaceName: envelope.workspace.workspaceSlug, }; } function failure(code: string, error: unknown, status = 500): Response { const explicit = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : code; return wireError(explicit, error instanceof Error ? error.message : String(error), status); } async function handleTriggerEvent( app: ColineAppV2, envelope: ColineProtocolEnvelope, ): Promise { const payload = triggerEventPayloadSchema.safeParse(envelope.payload); if (!payload.success) { return wireError("invalid_payload", "trigger.event payload is invalid.", 400); } if (!app.runtime.onEvent) { return wireError("unknown_event_handler", "This app has no onEvent handler.", 404); } try { await app.runtime.onEvent({ coline: createHttpCapabilities(payload.data.capabilities), workspace: workspaceFromEnvelope(envelope), app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, event: payload.data.event, attempt: payload.data.attempt, }); return json({ result: { ok: true } }, 200); } catch (error) { return failure("event_failed", error); } } async function handleEndpointRequest( app: ColineAppV2, envelope: ColineProtocolEnvelope, ): Promise { const payload = endpointRequestPayloadSchema.safeParse(envelope.payload); if (!payload.success) { return wireError("invalid_payload", "endpoint.request payload is invalid.", 400); } const request = payload.data.request; const handler = app.runtime.endpoints.get(request.endpointId); if (!handler) { return wireError("unknown_endpoint", `This app has no endpoint "${request.endpointId}".`, 404); } try { const response = await handler({ coline: createHttpCapabilities(payload.data.capabilities), workspace: workspaceFromEnvelope(envelope), app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, request, json: () => parseEndpointJson(request.body), }); return json({ result: toEndpointResponse(response) }, 200); } catch (error) { return failure("endpoint_failed", error); } } async function handleJobRun(app: ColineAppV2, envelope: ColineProtocolEnvelope): Promise { const payload = jobRunPayloadSchema.safeParse(envelope.payload); if (!payload.success) { return wireError("invalid_payload", "job.run payload is invalid.", 400); } const definition = app.runtime.jobs.get(payload.data.job.name); if (!definition) { return wireError("unknown_job", `This app has no job "${payload.data.job.name}".`, 404); } let input: unknown = payload.data.job.payload; if (definition.payload) { const parsed = definition.payload.safeParse(input); if (!parsed.success) { return wireError("invalid_job_payload", "Job payload failed schema validation.", 400); } input = parsed.data; } const coline = createHttpCapabilities(payload.data.capabilities); try { const outcome = await definition.run(input as never, { coline, workspace: workspaceFromEnvelope(envelope), app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, actor: payload.data.actor, job: createJobRunInfo(payload.data.capabilities, payload.data.job), }); return json({ result: toJobRunResult(outcome) }, 200); } catch (error) { return failure("job_failed", error); } } /** Signed requests older than this are rejected, so ids need only be remembered this long. */ const REPLAY_WINDOW_MS = 5 * 60_000; const MAX_REMEMBERED_DELIVERIES = 50_000; /** * Delivery ids seen within the acceptance window. Coline issues a fresh id * for every delivery, so a repeated id is a replay. In-process only: run * one instance, or share `seenDelivery` (e.g. Redis SET NX) across many. */ function createReplayGuard() { const seen = new Map(); return (deliveryId: string, now: number): boolean => { if (seen.size > MAX_REMEMBERED_DELIVERIES || seen.size % 512 === 0) { for (const [id, expiresAt] of seen) if (expiresAt <= now) seen.delete(id); } const expiresAt = seen.get(deliveryId); if (expiresAt !== undefined && expiresAt > now) return true; seen.set(deliveryId, now + 2 * REPLAY_WINDOW_MS); return false; }; } export function createColineAppHandler(params: { app: ColineAppV2; secret: string; /** * Returns true when this delivery id was already handled. Defaults to an * in-memory guard; pass a shared store when several instances serve the app. */ seenDelivery?: (deliveryId: string) => boolean | Promise; }): (request: Request) => Promise { const memoryGuard = createReplayGuard(); const seenDelivery = params.seenDelivery ?? ((deliveryId: string) => memoryGuard(deliveryId, Date.now())); return async (request) => { let envelope: ColineProtocolEnvelope; try { const parsed = await parseSignedProtocolRequest({ request, secret: params.secret, maxAgeMs: REPLAY_WINDOW_MS, }); envelope = parsed.envelope; if (await seenDelivery(envelope.deliveryId)) { throw new ColineProtocolError("replayed_delivery", "This delivery was already handled."); } } 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); case TRIGGER_EVENT_KIND: return handleTriggerEvent(params.app, envelope); case ENDPOINT_REQUEST_KIND: return handleEndpointRequest(params.app, envelope); case JOB_RUN_KIND: return handleJobRun(params.app, envelope); default: return wireError( "unsupported_kind", `This SDK version does not handle "${envelope.kind}". Upgrade @colineapp/sdk.`, 400, ); } }; }