The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| 4 | ColineAppV2, |
| 5 | ColineFileRenderContextV2, |
| 6 | ColineHomeRenderContextV2, |
| 7 | ColineScheduleContextV2, |
| 8 | ColineToolContext, |
| 9 | } from "../define/types"; |
| 10 | import { validateChatCardTree } from "../tree/chat-card"; |
| 11 | import { |
| 12 | ColineProtocolError, |
| 13 | parseSignedProtocolRequest, |
| 14 | type ColineProtocolEnvelope, |
| 15 | } from "./protocol"; |
| 16 | import { |
| 17 | RENDER_SURFACE_KIND, |
| 18 | renderSurfacePayloadSchema, |
| 19 | } from "./render-surface"; |
| 20 | import { |
| 21 | TOOL_INVOKE_KIND, |
| 22 | toolInvokePayloadSchema, |
| 23 | } from "./tool-invoke"; |
| 24 | import { |
| 25 | TRIGGER_SCHEDULE_KIND, |
| 26 | triggerSchedulePayloadSchema, |
| 27 | } from "./trigger-schedule"; |
| 28 | import { |
| 29 | ENDPOINT_REQUEST_KIND, |
| 30 | JOB_RUN_KIND, |
| 31 | TRIGGER_EVENT_KIND, |
| 32 | endpointRequestPayloadSchema, |
| 33 | jobRunPayloadSchema, |
| 34 | toEndpointResponse, |
| 35 | toJobRunResult, |
| 36 | triggerEventPayloadSchema, |
| 37 | } from "./kinds"; |
| 38 | import { createJobRunInfo, parseEndpointJson } from "./runtime-helpers"; |
| 39 |
| 40 | /** |
| 41 | * The external app server handler (plan §10.3, §9.3): ONE handler serves |
| 42 | * every Coline → app-backend request kind. Mount it at |
| 43 | * `POST {baseUrl}/coline/app`. |
| 44 | * |
| 45 | * ```ts |
| 46 | * // app/coline/app/route.ts (Next.js) — or any fetch-shaped server |
| 47 | * import app from "../../../app.config"; |
| 48 | * import { createColineAppHandler } from "@colineapp/sdk/v2"; |
| 49 | * |
| 50 | * export const POST = createColineAppHandler({ |
| 51 | * app, |
| 52 | * secret: process.env.COLINE_DELIVERY_SECRET!, |
| 53 | * }); |
| 54 | * ``` |
| 55 | * |
| 56 | * Kinds handled: |
| 57 | * - `tool.invoke` → the tool's `execute` with a capability-bound context |
| 58 | * - `render.surface` → the file type's tree render handlers, or `renderHome` |
| 59 | * |
| 60 | * Render contexts carry a read-ceiling invocation token: a render handler |
| 61 | * that attempts a mutation fails at Coline's capability gate, not here. |
| 62 | */ |
| 63 |
| 64 | interface WireError { |
| 65 | error: { code: string; message: string }; |
| 66 | } |
| 67 |
| 68 | function json(body: unknown, status: number): Response { |
| 69 | return new Response(JSON.stringify(body), { |
| 70 | status, |
| 71 | headers: { "content-type": "application/json" }, |
| 72 | }); |
| 73 | } |
| 74 |
| 75 | function wireError(code: string, message: string, status: number): Response { |
| 76 | const body: WireError = { error: { code, message } }; |
| 77 | return json(body, status); |
| 78 | } |
| 79 |
| 80 | function actorFromEnvelope(envelope: ColineProtocolEnvelope): ColineActorRef { |
| 81 | return envelope.actor.kind === "user" |
| 82 | ? { kind: "user", userId: envelope.actor.userId } |
| 83 | : { kind: "app" }; |
| 84 | } |
| 85 |
| 86 | async function handleToolInvoke( |
| 87 | app: ColineAppV2, |
| 88 | envelope: ColineProtocolEnvelope, |
| 89 | ): Promise<Response> { |
| 90 | const payload = toolInvokePayloadSchema.safeParse(envelope.payload); |
| 91 | if (!payload.success) { |
| 92 | return wireError("invalid_payload", "tool.invoke payload is invalid.", 400); |
| 93 | } |
| 94 |
| 95 | const tool = app.runtime.tools.get(payload.data.tool); |
| 96 | if (!tool) { |
| 97 | return wireError( |
| 98 | "unknown_tool", |
| 99 | `App "${app.manifest.key}" does not define a tool named "${payload.data.tool}".`, |
| 100 | 404, |
| 101 | ); |
| 102 | } |
| 103 |
| 104 | const input = tool.input.safeParse(payload.data.input); |
| 105 | if (!input.success) { |
| 106 | return wireError( |
| 107 | "invalid_input", |
| 108 | `Tool input is invalid: ${input.error.issues |
| 109 | .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) |
| 110 | .join("; ")}`, |
| 111 | 400, |
| 112 | ); |
| 113 | } |
| 114 |
| 115 | const context: ColineToolContext = { |
| 116 | coline: createHttpCapabilities(payload.data.capabilities), |
| 117 | workspace: { |
| 118 | workspaceId: envelope.workspace.workspaceId, |
| 119 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 120 | workspaceName: envelope.workspace.workspaceSlug, |
| 121 | }, |
| 122 | actor: actorFromEnvelope(envelope), |
| 123 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 124 | }; |
| 125 |
| 126 | try { |
| 127 | const result = await tool.execute(input.data as never, context); |
| 128 | if (result.card) { |
| 129 | const validation = validateChatCardTree(result.card); |
| 130 | if (!validation.ok) { |
| 131 | return wireError("invalid_card", `Tool result card rejected: ${validation.reason}`, 400); |
| 132 | } |
| 133 | } |
| 134 | return json( |
| 135 | { |
| 136 | result: { |
| 137 | ...(result.output !== undefined ? { output: result.output } : {}), |
| 138 | ...(result.card ? { card: result.card } : {}), |
| 139 | }, |
| 140 | }, |
| 141 | 200, |
| 142 | ); |
| 143 | } catch (error) { |
| 144 | return wireError( |
| 145 | "tool_execution_failed", |
| 146 | error instanceof Error ? error.message : String(error), |
| 147 | 500, |
| 148 | ); |
| 149 | } |
| 150 | } |
| 151 |
| 152 | async function handleRenderSurface( |
| 153 | app: ColineAppV2, |
| 154 | envelope: ColineProtocolEnvelope, |
| 155 | ): Promise<Response> { |
| 156 | const payload = renderSurfacePayloadSchema.safeParse(envelope.payload); |
| 157 | if (!payload.success) { |
| 158 | return wireError("invalid_payload", "render.surface payload is invalid.", 400); |
| 159 | } |
| 160 |
| 161 | const coline = createHttpCapabilities(payload.data.capabilities); |
| 162 | const base = { |
| 163 | coline, |
| 164 | workspace: { |
| 165 | workspaceId: envelope.workspace.workspaceId, |
| 166 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 167 | workspaceName: envelope.workspace.workspaceSlug, |
| 168 | }, |
| 169 | actor: envelope.actor.kind === "user" ? actorFromEnvelope(envelope) : null, |
| 170 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 171 | }; |
| 172 |
| 173 | try { |
| 174 | if (payload.data.surface === "home") { |
| 175 | if (!app.runtime.renderHome) { |
| 176 | return wireError("unknown_surface", "This app has no tree home surface.", 404); |
| 177 | } |
| 178 | const context: ColineHomeRenderContextV2 = base; |
| 179 | const tree = await app.runtime.renderHome(context); |
| 180 | return json({ result: { tree } }, 200); |
| 181 | } |
| 182 |
| 183 | const file = payload.data.file; |
| 184 | if (!file) { |
| 185 | return wireError("invalid_payload", "File surfaces require a file reference.", 400); |
| 186 | } |
| 187 | const runtime = app.runtime.fileTypes.get(file.typeKey); |
| 188 | const handler = |
| 189 | payload.data.surface === "editor" |
| 190 | ? runtime?.renderEditor |
| 191 | : payload.data.surface === "preview" |
| 192 | ? runtime?.renderPreview |
| 193 | : runtime?.renderInline; |
| 194 | if (!handler) { |
| 195 | return wireError( |
| 196 | "unknown_surface", |
| 197 | `File type "${file.typeKey}" has no ${payload.data.surface} handler.`, |
| 198 | 404, |
| 199 | ); |
| 200 | } |
| 201 |
| 202 | const context: ColineFileRenderContextV2 = { |
| 203 | ...base, |
| 204 | file, |
| 205 | document: payload.data.document ?? {}, |
| 206 | }; |
| 207 | const tree = await handler(context); |
| 208 |
| 209 | if (payload.data.surface === "inline") { |
| 210 | const validation = validateChatCardTree(tree); |
| 211 | if (!validation.ok) { |
| 212 | return wireError("invalid_tree", `Inline surface rejected: ${validation.reason}`, 400); |
| 213 | } |
| 214 | } |
| 215 | return json({ result: { tree } }, 200); |
| 216 | } catch (error) { |
| 217 | return wireError( |
| 218 | "render_failed", |
| 219 | error instanceof Error ? error.message : String(error), |
| 220 | 500, |
| 221 | ); |
| 222 | } |
| 223 | } |
| 224 |
| 225 | async function handleTriggerSchedule( |
| 226 | app: ColineAppV2, |
| 227 | envelope: ColineProtocolEnvelope, |
| 228 | ): Promise<Response> { |
| 229 | const payload = triggerSchedulePayloadSchema.safeParse(envelope.payload); |
| 230 | if (!payload.success) { |
| 231 | return wireError("invalid_payload", "trigger.schedule payload is invalid.", 400); |
| 232 | } |
| 233 | if (!app.runtime.onSchedule) { |
| 234 | return wireError("unknown_schedule", "This app has no schedule handler.", 404); |
| 235 | } |
| 236 | const context: ColineScheduleContextV2 = { |
| 237 | coline: createHttpCapabilities(payload.data.capabilities), |
| 238 | workspace: { |
| 239 | workspaceId: envelope.workspace.workspaceId, |
| 240 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 241 | workspaceName: envelope.workspace.workspaceSlug, |
| 242 | }, |
| 243 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 244 | scheduleId: payload.data.scheduleId, |
| 245 | firedAt: payload.data.firedAt, |
| 246 | }; |
| 247 | try { |
| 248 | await app.runtime.onSchedule(context); |
| 249 | return json({ result: { ok: true } }, 200); |
| 250 | } catch (error) { |
| 251 | return wireError( |
| 252 | "schedule_failed", |
| 253 | error instanceof Error ? error.message : String(error), |
| 254 | 500, |
| 255 | ); |
| 256 | } |
| 257 | } |
| 258 |
| 259 | function workspaceFromEnvelope(envelope: ColineProtocolEnvelope) { |
| 260 | return { |
| 261 | workspaceId: envelope.workspace.workspaceId, |
| 262 | workspaceSlug: envelope.workspace.workspaceSlug, |
| 263 | workspaceName: envelope.workspace.workspaceSlug, |
| 264 | }; |
| 265 | } |
| 266 |
| 267 | function failure(code: string, error: unknown, status = 500): Response { |
| 268 | const explicit = |
| 269 | error && typeof error === "object" && "code" in error && typeof error.code === "string" |
| 270 | ? error.code |
| 271 | : code; |
| 272 | return wireError(explicit, error instanceof Error ? error.message : String(error), status); |
| 273 | } |
| 274 |
| 275 | async function handleTriggerEvent( |
| 276 | app: ColineAppV2, |
| 277 | envelope: ColineProtocolEnvelope, |
| 278 | ): Promise<Response> { |
| 279 | const payload = triggerEventPayloadSchema.safeParse(envelope.payload); |
| 280 | if (!payload.success) { |
| 281 | return wireError("invalid_payload", "trigger.event payload is invalid.", 400); |
| 282 | } |
| 283 | if (!app.runtime.onEvent) { |
| 284 | return wireError("unknown_event_handler", "This app has no onEvent handler.", 404); |
| 285 | } |
| 286 | try { |
| 287 | await app.runtime.onEvent({ |
| 288 | coline: createHttpCapabilities(payload.data.capabilities), |
| 289 | workspace: workspaceFromEnvelope(envelope), |
| 290 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 291 | event: payload.data.event, |
| 292 | attempt: payload.data.attempt, |
| 293 | }); |
| 294 | return json({ result: { ok: true } }, 200); |
| 295 | } catch (error) { |
| 296 | return failure("event_failed", error); |
| 297 | } |
| 298 | } |
| 299 |
| 300 | async function handleEndpointRequest( |
| 301 | app: ColineAppV2, |
| 302 | envelope: ColineProtocolEnvelope, |
| 303 | ): Promise<Response> { |
| 304 | const payload = endpointRequestPayloadSchema.safeParse(envelope.payload); |
| 305 | if (!payload.success) { |
| 306 | return wireError("invalid_payload", "endpoint.request payload is invalid.", 400); |
| 307 | } |
| 308 | const request = payload.data.request; |
| 309 | const handler = app.runtime.endpoints.get(request.endpointId); |
| 310 | if (!handler) { |
| 311 | return wireError("unknown_endpoint", `This app has no endpoint "${request.endpointId}".`, 404); |
| 312 | } |
| 313 | try { |
| 314 | const response = await handler({ |
| 315 | coline: createHttpCapabilities(payload.data.capabilities), |
| 316 | workspace: workspaceFromEnvelope(envelope), |
| 317 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 318 | request, |
| 319 | json: <T>() => parseEndpointJson<T>(request.body), |
| 320 | }); |
| 321 | return json({ result: toEndpointResponse(response) }, 200); |
| 322 | } catch (error) { |
| 323 | return failure("endpoint_failed", error); |
| 324 | } |
| 325 | } |
| 326 |
| 327 | async function handleJobRun(app: ColineAppV2, envelope: ColineProtocolEnvelope): Promise<Response> { |
| 328 | const payload = jobRunPayloadSchema.safeParse(envelope.payload); |
| 329 | if (!payload.success) { |
| 330 | return wireError("invalid_payload", "job.run payload is invalid.", 400); |
| 331 | } |
| 332 | const definition = app.runtime.jobs.get(payload.data.job.name); |
| 333 | if (!definition) { |
| 334 | return wireError("unknown_job", `This app has no job "${payload.data.job.name}".`, 404); |
| 335 | } |
| 336 | let input: unknown = payload.data.job.payload; |
| 337 | if (definition.payload) { |
| 338 | const parsed = definition.payload.safeParse(input); |
| 339 | if (!parsed.success) { |
| 340 | return wireError("invalid_job_payload", "Job payload failed schema validation.", 400); |
| 341 | } |
| 342 | input = parsed.data; |
| 343 | } |
| 344 | const coline = createHttpCapabilities(payload.data.capabilities); |
| 345 | try { |
| 346 | const outcome = await definition.run(input as never, { |
| 347 | coline, |
| 348 | workspace: workspaceFromEnvelope(envelope), |
| 349 | app: { appKey: envelope.app.appKey, installId: envelope.app.installId }, |
| 350 | actor: payload.data.actor, |
| 351 | job: createJobRunInfo(payload.data.capabilities, payload.data.job), |
| 352 | }); |
| 353 | return json({ result: toJobRunResult(outcome) }, 200); |
| 354 | } catch (error) { |
| 355 | return failure("job_failed", error); |
| 356 | } |
| 357 | } |
| 358 |
| 359 | /** Signed requests older than this are rejected, so ids need only be remembered this long. */ |
| 360 | const REPLAY_WINDOW_MS = 5 * 60_000; |
| 361 | const MAX_REMEMBERED_DELIVERIES = 50_000; |
| 362 |
| 363 | /** |
| 364 | * Delivery ids seen within the acceptance window. Coline issues a fresh id |
| 365 | * for every delivery, so a repeated id is a replay. In-process only: run |
| 366 | * one instance, or share `seenDelivery` (e.g. Redis SET NX) across many. |
| 367 | */ |
| 368 | function createReplayGuard() { |
| 369 | const seen = new Map<string, number>(); |
| 370 | return (deliveryId: string, now: number): boolean => { |
| 371 | if (seen.size > MAX_REMEMBERED_DELIVERIES || seen.size % 512 === 0) { |
| 372 | for (const [id, expiresAt] of seen) if (expiresAt <= now) seen.delete(id); |
| 373 | } |
| 374 | const expiresAt = seen.get(deliveryId); |
| 375 | if (expiresAt !== undefined && expiresAt > now) return true; |
| 376 | seen.set(deliveryId, now + 2 * REPLAY_WINDOW_MS); |
| 377 | return false; |
| 378 | }; |
| 379 | } |
| 380 |
| 381 | export function createColineAppHandler(params: { |
| 382 | app: ColineAppV2; |
| 383 | secret: string; |
| 384 | /** |
| 385 | * Returns true when this delivery id was already handled. Defaults to an |
| 386 | * in-memory guard; pass a shared store when several instances serve the app. |
| 387 | */ |
| 388 | seenDelivery?: (deliveryId: string) => boolean | Promise<boolean>; |
| 389 | }): (request: Request) => Promise<Response> { |
| 390 | const memoryGuard = createReplayGuard(); |
| 391 | const seenDelivery = |
| 392 | params.seenDelivery ?? ((deliveryId: string) => memoryGuard(deliveryId, Date.now())); |
| 393 | return async (request) => { |
| 394 | let envelope: ColineProtocolEnvelope; |
| 395 | try { |
| 396 | const parsed = await parseSignedProtocolRequest({ |
| 397 | request, |
| 398 | secret: params.secret, |
| 399 | maxAgeMs: REPLAY_WINDOW_MS, |
| 400 | }); |
| 401 | envelope = parsed.envelope; |
| 402 | if (await seenDelivery(envelope.deliveryId)) { |
| 403 | throw new ColineProtocolError("replayed_delivery", "This delivery was already handled."); |
| 404 | } |
| 405 | } catch (error) { |
| 406 | if (error instanceof ColineProtocolError) { |
| 407 | return wireError(error.code, error.message, 401); |
| 408 | } |
| 409 | throw error; |
| 410 | } |
| 411 |
| 412 | switch (envelope.kind) { |
| 413 | case TOOL_INVOKE_KIND: |
| 414 | return handleToolInvoke(params.app, envelope); |
| 415 | case RENDER_SURFACE_KIND: |
| 416 | return handleRenderSurface(params.app, envelope); |
| 417 | case TRIGGER_SCHEDULE_KIND: |
| 418 | return handleTriggerSchedule(params.app, envelope); |
| 419 | case TRIGGER_EVENT_KIND: |
| 420 | return handleTriggerEvent(params.app, envelope); |
| 421 | case ENDPOINT_REQUEST_KIND: |
| 422 | return handleEndpointRequest(params.app, envelope); |
| 423 | case JOB_RUN_KIND: |
| 424 | return handleJobRun(params.app, envelope); |
| 425 | default: |
| 426 | return wireError( |
| 427 | "unsupported_kind", |
| 428 | `This SDK version does not handle "${envelope.kind}". Upgrade @colineapp/sdk.`, |
| 429 | 400, |
| 430 | ); |
| 431 | } |
| 432 | }; |
| 433 | } |
| 434 |