@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/agent/sites-agent-context.js +3 -2
  2. package/dist/agent/sites-agent-shared.js +1 -0
  3. package/dist/chat/anthropic-planner.js +3 -3
  4. package/dist/chat/chat-pipeline.js +122 -20
  5. package/dist/chat/gemini-planner.js +3 -3
  6. package/dist/chat/planner.js +7 -5
  7. package/dist/chat/prompts.js +6 -1
  8. package/dist/cms/adapter.d.ts +159 -1
  9. package/dist/cms/adapter.js +19 -1
  10. package/dist/cms/bootstrap.d.ts +46 -1
  11. package/dist/cms/bootstrap.js +126 -2
  12. package/dist/cms/index.d.ts +3 -2
  13. package/dist/cms/index.js +2 -1
  14. package/dist/errors.d.ts +9 -1
  15. package/dist/handler/auth.d.ts +79 -0
  16. package/dist/handler/auth.js +113 -0
  17. package/dist/handler/create-orchestrator.d.ts +205 -0
  18. package/dist/handler/create-orchestrator.js +1599 -0
  19. package/dist/http/access-tokens.d.ts +58 -0
  20. package/dist/http/access-tokens.js +161 -0
  21. package/dist/http/audio-actions.d.ts +121 -0
  22. package/dist/http/audio-actions.js +248 -0
  23. package/dist/http/blocks-actions.d.ts +31 -0
  24. package/dist/http/blocks-actions.js +31 -0
  25. package/dist/http/draft-provenance.d.ts +68 -0
  26. package/dist/http/draft-provenance.js +101 -0
  27. package/dist/http/history-actions.d.ts +58 -0
  28. package/dist/http/history-actions.js +169 -0
  29. package/dist/http/image-generate-actions.d.ts +268 -0
  30. package/dist/http/image-generate-actions.js +546 -0
  31. package/dist/http/ops-actions.d.ts +51 -0
  32. package/dist/http/ops-actions.js +79 -0
  33. package/dist/http/publish-actions.d.ts +153 -0
  34. package/dist/http/publish-actions.js +323 -0
  35. package/dist/http/restore-actions.d.ts +67 -0
  36. package/dist/http/restore-actions.js +145 -0
  37. package/dist/http/screenshot-actions.d.ts +108 -0
  38. package/dist/http/screenshot-actions.js +181 -0
  39. package/dist/http/session-actions.d.ts +35 -0
  40. package/dist/http/session-actions.js +98 -0
  41. package/dist/http/telemetry-feedback-actions.d.ts +53 -0
  42. package/dist/http/telemetry-feedback-actions.js +68 -0
  43. package/dist/http/unsplash-actions.d.ts +64 -0
  44. package/dist/http/unsplash-actions.js +81 -0
  45. package/dist/http/variations-actions.d.ts +102 -0
  46. package/dist/http/variations-actions.js +104 -0
  47. package/dist/index.d.ts +4 -1
  48. package/dist/index.js +21 -1
  49. package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
  50. package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
  51. package/dist/nlp/deterministic-planner-suggestions.js +37 -11
  52. package/dist/nlp/plan-normalizer.js +18 -2
  53. package/dist/ops/ops-engine.js +219 -14
  54. package/dist/state/session-state.d.ts +56 -1
  55. package/dist/state/session-state.js +92 -6
  56. package/dist/state/sqlite-store-singleton.d.ts +22 -0
  57. package/dist/state/sqlite-store-singleton.js +49 -1
  58. package/dist/state/sqlite-store.d.ts +5 -0
  59. package/dist/state/sqlite-store.js +125 -2
  60. package/dist/telemetry/chat-telemetry.js +6 -1
  61. package/package.json +12 -16
@@ -0,0 +1,1599 @@
1
+ // createOrchestrator — the Web-standard `(Request) => Promise<Response>` face of
2
+ // this package, for mounting the orchestrator at a route in someone else's
3
+ // Next.js app (`app/api/avocado/[[...path]]/route.ts`). apps/orchestrator is the
4
+ // same brain behind Fastify; the two wrappers are peers.
5
+ //
6
+ // It lived in packages/site-sdk until the package boundary was drawn. It never
7
+ // touched anything else in that package — no React, no Next — so all it did
8
+ // there was make a published package reach 78 symbols across 25 internal
9
+ // modules of another published package, through orchestrator-core's
10
+ // `"./*": "./src/*.ts"` wildcard. Next to the code it wraps, those are relative
11
+ // imports, and core can publish two subpaths instead of 110. Reached by
12
+ // integrators as `@avocadostudio-ai/site-sdk/server`, which is now a re-export.
13
+ //
14
+ // Routes answered: see SUPPORTED_ROUTES below, which is the 405 body and is
15
+ // kept honest by library-mode-routes.test.ts — it parses the branches out of
16
+ // this file and diffs them against the endpoints apps/editor actually calls.
17
+ //
18
+ // Storage note: image upload writes to local disk (POST /image/upload + GET
19
+ // /generated-images/:fileName) — POC-grade, see config.imageDir.
20
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
21
+ import { resolve, basename } from "node:path";
22
+ import { randomUUID } from "node:crypto";
23
+ import { z } from "zod";
24
+ import { operationSchema, blockManifestSchema, siteConfigSchema, declareBlockCatalogue, undeclaredBlockTypes } from "@avocadostudio-ai/shared";
25
+ import { chatRequestBodySchema } from "../nlp/intent-detection.js";
26
+ import { applyOpsAtomically, pickFocusBlockId, pickUpdatedSlug, toErrorDetail, classifyGuardrailError } from "../ops/ops-engine.js";
27
+ import { runChatStream, formatSseFrame } from "../http/chat-stream.js";
28
+ import { ResumableStreamStore, runResumableChatStream, TooManyPendingStreamsError, isTerminalState } from "../http/chat-stream-resumable.js";
29
+ import { runChatPipeline, collectMentionedSlugsFromOps } from "../chat/chat-pipeline.js";
30
+ import { createChatTelemetryStore } from "../telemetry/chat-telemetry.js";
31
+ import { createToolRuntime } from "../tools/runtime.js";
32
+ import { loadStateFromDisk, scopedSessionKey, getSessionPages, getPage, getSiteConfig, setSiteConfig, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, schedulePersistState, normalizeSiteId, publishStatusBySession, pushPublishLogEntry, persistenceHealth, persistenceWarning } from "../state/session-state.js";
33
+ import { historyStatus, historyLog, historyUndoAction, historyRedoAction, historyRestoreAction } from "../http/history-actions.js";
34
+ import { whoamiAction } from "../http/session-actions.js";
35
+ import { blocksManifestAction } from "../http/blocks-actions.js";
36
+ import { screenshotAction } from "../http/screenshot-actions.js";
37
+ import { fileImageStore, formatImageChatFrame, generateImageAction, imageChatAction, imageChatStreamAction, interpretImageAction, validateImageChatRequest } from "../http/image-generate-actions.js";
38
+ import { transcribeAudioAction, transcriptionUnavailable, validateAudioInput } from "../http/audio-actions.js";
39
+ import { formatVariationFrame, parseVariationRequest, scopeVariationSession, variationsAction, variationsStreamAction } from "../http/variations-actions.js";
40
+ import { opsDryRunAction, describeAppliedOps } from "../http/ops-actions.js";
41
+ import { describeDraft } from "../http/draft-provenance.js";
42
+ import { buildPublishSummary, publishDiffAction, publishLogAction, publishStatusAction } from "../http/publish-actions.js";
43
+ import { restoreSnapshotApply, restoreSnapshotDelete, restoreSnapshotsList } from "../http/restore-actions.js";
44
+ import { unsplashSearchAction } from "../http/unsplash-actions.js";
45
+ import { telemetryFeedbackListAction, telemetryFeedbackSubmitAction } from "../http/telemetry-feedback-actions.js";
46
+ import { createFeedbackStore } from "../telemetry/feedback-store.js";
47
+ import { consoleLogger } from "../logger.js";
48
+ import { createCmsBootstrapCache } from "../cms/bootstrap.js";
49
+ import { resolveCapabilities } from "../cms/adapter.js";
50
+ import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../http/access-tokens.js";
51
+ import { checkAuth, resolveAuth } from "./auth.js";
52
+ const defaultModelLookup = () => ({
53
+ openai: {
54
+ fast: process.env.OPENAI_MODEL_FAST ?? "gpt-4o-mini",
55
+ balanced: process.env.OPENAI_MODEL_BALANCED ?? "gpt-4o",
56
+ reasoning: process.env.OPENAI_MODEL_REASONING ?? "o1",
57
+ codex: process.env.OPENAI_MODEL_CODEX ?? "o3"
58
+ },
59
+ anthropic: {
60
+ fast: process.env.ANTHROPIC_MODEL_FAST ?? "claude-haiku-4-5-20251001",
61
+ balanced: process.env.ANTHROPIC_MODEL_BALANCED ?? "claude-sonnet-5",
62
+ reasoning: process.env.ANTHROPIC_MODEL_REASONING ?? "claude-sonnet-5",
63
+ codex: process.env.ANTHROPIC_MODEL_CODEX ?? "claude-opus-4-8"
64
+ },
65
+ gemini: {
66
+ fast: process.env.GOOGLE_GENAI_MODEL_FAST ?? "gemini-2.5-flash",
67
+ balanced: process.env.GOOGLE_GENAI_MODEL_BALANCED ?? "gemini-2.5-flash",
68
+ reasoning: process.env.GOOGLE_GENAI_MODEL_REASONING ?? "gemini-2.5-pro",
69
+ codex: process.env.GOOGLE_GENAI_MODEL_CODEX ?? "gemini-2.5-pro"
70
+ }
71
+ });
72
+ const defaultProviders = () => [
73
+ ...(process.env.OPENAI_API_KEY ? ["openai"] : []),
74
+ ...(process.env.ANTHROPIC_API_KEY ? ["anthropic"] : []),
75
+ ...(process.env.GOOGLE_GENAI_API_KEY ? ["gemini"] : [])
76
+ ];
77
+ async function buildRuntime(config) {
78
+ const log = config.logger ?? consoleLogger();
79
+ // Register host-app block schemas BEFORE the planner ever validates ops.
80
+ // Done here rather than at module load so the host's overrides land on the
81
+ // shared globalThis registry after any transitive canonical re-registration
82
+ // from @avocadostudio-ai/shared has already fired.
83
+ if (config.registerBlocks) {
84
+ try {
85
+ config.registerBlocks();
86
+ }
87
+ catch (err) {
88
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "registerBlocks() threw — continuing with whatever was already registered");
89
+ }
90
+ }
91
+ const chatTelemetry = createChatTelemetryStore({
92
+ filePath: process.env.CHAT_TELEMETRY_FILE ?? "./.data/chat-telemetry.ndjson",
93
+ limit: Number(process.env.CHAT_TELEMETRY_LIMIT ?? 500),
94
+ persistEnabled: !/^(0|false|no|off)$/i.test((process.env.CHAT_TELEMETRY_PERSIST ?? "1").trim()),
95
+ logger: log
96
+ });
97
+ const toolRuntime = await createToolRuntime({ logger: log });
98
+ if (config.builtinTools) {
99
+ // Dynamic import so the builtins module (and its image/ + googleapis +
100
+ // sharp transitive deps) isn't pulled into the bundle when the consumer
101
+ // doesn't opt in.
102
+ const { registerDefaultBuiltins } = await import("../tools/builtin-registrations.js");
103
+ registerDefaultBuiltins(toolRuntime.registry, Array.isArray(config.builtinTools) ? { include: config.builtinTools } : {});
104
+ }
105
+ const pipelineCtx = {
106
+ log,
107
+ chatTelemetry,
108
+ modelLookup: config.modelLookup ?? defaultModelLookup(),
109
+ availableProviders: config.availableProviders ?? defaultProviders(),
110
+ toolRuntime
111
+ };
112
+ const ready = (async () => {
113
+ await loadStateFromDisk(log);
114
+ await chatTelemetry.loadFromDisk();
115
+ })();
116
+ const resumableStore = new ResumableStreamStore();
117
+ // Resolved once, here, so `/whoami` and `/status/planner` cannot answer the
118
+ // same question differently.
119
+ const capabilities = resolveCapabilities(config.adapter, config.capabilities);
120
+ const bootstrapCache = createCmsBootstrapCache({ capabilities: config.capabilities });
121
+ /*
122
+ * Start the adapter's page read now rather than on the first request.
123
+ *
124
+ * `ensure()` is lazy, so without this the first caller pays for the whole
125
+ * CMS read: Paintball Arena Bern's adapter is 45 sequential Sanity reads
126
+ * behind a dev-server compile, and the `whoami` an MCP agent opens with took
127
+ * four minutes — long enough that the host times out and the agent reports
128
+ * the site as down. Fire-and-forget by contract: a failed warm logs and is
129
+ * forgotten, and `ensure()` still does its own fetch.
130
+ */
131
+ bootstrapCache.warm(config.adapter ?? null, log);
132
+ return {
133
+ pipelineCtx,
134
+ ready,
135
+ resumableStore,
136
+ log,
137
+ adapter: config.adapter ?? null,
138
+ capabilities,
139
+ bootstrapCache
140
+ };
141
+ }
142
+ /*
143
+ * Takes the *resolved* CORS map rather than the raw Origin header. It used to
144
+ * echo the origin directly, which meant every SSE response granted a
145
+ * cross-origin read that `corsHeadersFor` would have refused — the allow-list
146
+ * governed the JSON routes and the streaming ones ignored it.
147
+ */
148
+ const SSE_HEADERS = (cors) => ({
149
+ "content-type": "text/event-stream",
150
+ "cache-control": "no-cache, no-transform",
151
+ "connection": "keep-alive",
152
+ "x-accel-buffering": "no",
153
+ ...cors
154
+ });
155
+ function corsHeadersFor(request, config) {
156
+ if (config.corsOrigins === null)
157
+ return {};
158
+ const origin = request.headers.get("origin") ?? "*";
159
+ if (config.corsOrigins === "*") {
160
+ return { "access-control-allow-origin": origin, "vary": "Origin" };
161
+ }
162
+ if (config.corsOrigins === undefined) {
163
+ /*
164
+ * Unset used to mean "reflect anything", which on a production domain hands
165
+ * every other site on the web a cross-origin read of this orchestrator. In
166
+ * production, unset now means *no* CORS headers: a same-origin editor mount
167
+ * still works (the browser never asks), and a cross-origin one has to be
168
+ * named. Outside production it keeps reflecting, because the editor runs on
169
+ * :4100 against a site on :3000 and making every developer configure that is
170
+ * how a safe default gets set to "*" in a hurry.
171
+ */
172
+ return isProductionEnv() ? {} : { "access-control-allow-origin": origin, "vary": "Origin" };
173
+ }
174
+ return config.corsOrigins.includes(origin)
175
+ ? { "access-control-allow-origin": origin, "vary": "Origin" }
176
+ : {};
177
+ }
178
+ function isProductionEnv() {
179
+ return (process.env.NODE_ENV ?? "").trim() === "production";
180
+ }
181
+ function jsonResponse(body, init = {}) {
182
+ return new Response(JSON.stringify(body), {
183
+ status: init.status ?? 200,
184
+ headers: { "content-type": "application/json", ...(init.cors ?? {}) }
185
+ });
186
+ }
187
+ /** Send an `ActionResult` from orchestrator-core as a `Response`. */
188
+ /**
189
+ * Every path this handler answers, for the 405 body.
190
+ *
191
+ * A hand-written list of what is supported is a list of what is silently
192
+ * broken as soon as it drifts, and this one had already drifted: it never
193
+ * learned about `/history/*`, so an integrator reading the error was told undo
194
+ * was unavailable on a build that served it. `library-mode-routes.test.ts`
195
+ * parses the branches below and fails if they and this disagree, which is the
196
+ * only thing that keeps a list like this honest.
197
+ */
198
+ const SUPPORTED_ROUTES = [
199
+ "GET /auth/status",
200
+ "POST /auth/verify",
201
+ "POST /chat",
202
+ "POST /chat/stream",
203
+ "POST /chat/start",
204
+ "GET /chat/stream",
205
+ "POST /chat/cancel",
206
+ "POST /chat/variations",
207
+ "POST /chat/variations/stream",
208
+ "POST /publish",
209
+ "GET /status/planner",
210
+ "GET /draft/pages",
211
+ "GET /draft/slugs",
212
+ "POST /draft/bootstrap",
213
+ "GET+PUT /draft/site-config",
214
+ "POST /ops",
215
+ "GET /history/status",
216
+ "GET /history/log",
217
+ "POST /history/undo",
218
+ "POST /history/redo",
219
+ "POST /history/restore",
220
+ "GET /whoami",
221
+ "GET /blocks/manifest",
222
+ "GET /sites",
223
+ "POST /sites/register",
224
+ "GET /publish/log",
225
+ "GET /publish/status",
226
+ "GET /publish/diff",
227
+ "GET /restore/snapshots",
228
+ "POST /restore/snapshot",
229
+ "DELETE /restore/snapshot",
230
+ "GET /unsplash/search",
231
+ "GET+POST /telemetry/chat/feedback",
232
+ "POST /preview/screenshot",
233
+ "POST /audio/transcribe",
234
+ "POST /image/generate",
235
+ "POST /image/generate/chat",
236
+ "POST /image/interpret",
237
+ "POST /image/upload",
238
+ "GET /generated-images/:fileName"
239
+ ];
240
+ /**
241
+ * Send an ActionResult from an `http/*-actions` module as a Response.
242
+ *
243
+ * The parameter used to be typed structurally, because importing the type
244
+ * across the package boundary was more ceremony than restating it. From inside
245
+ * the package it is just a sibling import, so this is the real type now.
246
+ */
247
+ function actionResponse(result, cors) {
248
+ return jsonResponse(result.body, { status: result.code, cors });
249
+ }
250
+ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB
251
+ // MIME ⇄ extension for the local image store. Kept narrow on purpose: only the
252
+ // formats a browser <img>/Next <Image> renders. Unknown types are rejected.
253
+ const MIME_TO_EXT = {
254
+ "image/png": "png",
255
+ "image/jpeg": "jpg",
256
+ "image/webp": "webp",
257
+ "image/gif": "gif",
258
+ "image/avif": "avif",
259
+ "image/svg+xml": "svg"
260
+ };
261
+ const EXT_TO_MIME = {
262
+ png: "image/png",
263
+ jpg: "image/jpeg",
264
+ jpeg: "image/jpeg",
265
+ webp: "image/webp",
266
+ gif: "image/gif",
267
+ avif: "image/avif",
268
+ svg: "image/svg+xml"
269
+ };
270
+ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "../cms/index.js";
271
+ function stripBasePath(pathname, basePath) {
272
+ if (!basePath)
273
+ return pathname || "/";
274
+ if (pathname === basePath)
275
+ return "/";
276
+ if (pathname.startsWith(basePath + "/"))
277
+ return pathname.slice(basePath.length) || "/";
278
+ return pathname || "/";
279
+ }
280
+ /**
281
+ * Build a Web-standard request handler that wraps the orchestrator brain.
282
+ *
283
+ * Usage in Next.js App Router (`app/api/avocado/[[...path]]/route.ts`):
284
+ *
285
+ * export const runtime = "nodejs"
286
+ * const handler = createOrchestrator()
287
+ * export const POST = handler
288
+ * export const OPTIONS = handler
289
+ */
290
+ export function createOrchestrator(config = {}) {
291
+ const basePath = config.basePath ?? "/api/avocado";
292
+ // When an adapter is configured, force-scope sessions so the orchestrator's
293
+ // built-in demo-content seed path (triggered when a session key has no `::`)
294
+ // doesn't fire ahead of the adapter. Without this, the first chat turn ends
295
+ // up editing the bundled demo pages instead of the site's real content.
296
+ const effectiveSiteId = config.adapter ? (config.siteId ?? "library") : config.siteId;
297
+ /*
298
+ * Declared at mount, before any request can build a manifest. The registry
299
+ * holds this on `globalThis`, so it survives Next duplicating these modules
300
+ * across the RSC / SSR / route-handler layers — the declaration made here has
301
+ * to be visible to `/api/editor/blocks` in another copy.
302
+ */
303
+ if (config.blockTypes)
304
+ declareBlockCatalogue(config.blockTypes);
305
+ const scope = (session, bodySiteId) => scopedSessionKey(session, effectiveSiteId ?? bodySiteId);
306
+ const imageDir = config.imageDir ?? resolve(process.cwd(), ".data/generated-images");
307
+ /*
308
+ * Built on first use rather than at mount: the store opens an append-only
309
+ * file, and a site that never receives a rating should not create one.
310
+ */
311
+ let feedbackStore = null;
312
+ const getFeedbackStore = (logger) => {
313
+ if (!feedbackStore) {
314
+ feedbackStore = createFeedbackStore({
315
+ filePath: process.env.FEEDBACK_FILE ?? resolve(process.cwd(), ".data/chat-feedback.ndjson"),
316
+ limit: Number(process.env.FEEDBACK_LIMIT ?? 1000),
317
+ logger
318
+ });
319
+ }
320
+ return feedbackStore;
321
+ };
322
+ let runtimePromise = null;
323
+ const getRuntime = () => {
324
+ if (!runtimePromise)
325
+ runtimePromise = buildRuntime(config);
326
+ return runtimePromise;
327
+ };
328
+ /*
329
+ * The gate has to be able to speak before `buildRuntime` has run — it refuses
330
+ * requests that never reach the runtime — so it gets its own logger rather
331
+ * than waiting for `runtime.log`.
332
+ */
333
+ const gateLogger = config.logger ?? consoleLogger();
334
+ {
335
+ const resolved = resolveAuth(config.auth);
336
+ const line = `[auth] library mode: ${resolved.mode} — ${resolved.reason}`;
337
+ if (resolved.mode === "closed")
338
+ gateLogger.error(line);
339
+ else if (resolved.mode === "open-dev")
340
+ gateLogger.warn(line);
341
+ else
342
+ gateLogger.info(line);
343
+ }
344
+ const handler = async function handler(request) {
345
+ const url = new URL(request.url);
346
+ const path = stripBasePath(url.pathname, basePath);
347
+ const cors = corsHeadersFor(request, config);
348
+ if (request.method === "OPTIONS") {
349
+ return new Response(null, {
350
+ status: 204,
351
+ headers: {
352
+ ...cors,
353
+ /*
354
+ * DELETE is here because /restore/snapshot needs it, and the header
355
+ * list because the editor's fetch shim attaches `x-access-token` to
356
+ * every orchestrator request. Advertising only `content-type` meant a
357
+ * browser refused the preflight the moment auth existed — the gate
358
+ * would have been unreachable from the very client it is for.
359
+ */
360
+ "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
361
+ "access-control-allow-headers": "content-type, x-access-token, authorization",
362
+ "access-control-max-age": "600"
363
+ }
364
+ });
365
+ }
366
+ /*
367
+ * The gate, before anything reads a body or touches the store. Public paths
368
+ * (the /auth exchange itself, and image reads an <img> tag cannot
369
+ * authenticate) pass through inside checkAuth.
370
+ */
371
+ const gate = await checkAuth({ request, path, auth: config.auth, cors, log: gateLogger });
372
+ if (gate.response)
373
+ return gate.response;
374
+ if (request.method === "GET" && path === "/auth/status") {
375
+ /*
376
+ * `gateEnabled` answers one question for the editor's PasswordGate: "do I
377
+ * prompt for a password?". Only the built-in password gate can say yes —
378
+ * a host's own `auth` hook has no password to collect, and telling the
379
+ * editor to prompt for one it cannot verify would trap the user in a form
380
+ * that never succeeds. Such a host authenticates the browser its own way,
381
+ * before the editor ever loads.
382
+ */
383
+ return jsonResponse({ gateEnabled: isAccessGateEnabled() }, { cors });
384
+ }
385
+ if (request.method === "POST" && path === "/auth/verify") {
386
+ if (!isAccessGateEnabled()) {
387
+ // Open by choice, or gated by something other than a password. Hand back
388
+ // a token anyway so the client's code path is identical either way; if a
389
+ // token gate is running, this one is not valid for it.
390
+ return jsonResponse({ ok: true, accessToken: mintAccessToken() }, { cors });
391
+ }
392
+ let body = {};
393
+ try {
394
+ body = (await request.json());
395
+ }
396
+ catch {
397
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
398
+ }
399
+ if (!body.password)
400
+ return jsonResponse({ error: "password is required" }, { status: 400, cors });
401
+ if (!verifyAccessPassword(body.password)) {
402
+ return jsonResponse({ error: "incorrect password" }, { status: 401, cors });
403
+ }
404
+ return jsonResponse({ ok: true, accessToken: mintAccessToken() }, { cors });
405
+ }
406
+ if (request.method === "POST" && path === "/chat") {
407
+ const runtime = await getRuntime();
408
+ await runtime.ready;
409
+ let raw;
410
+ try {
411
+ raw = await request.json();
412
+ }
413
+ catch {
414
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
415
+ }
416
+ const parsed = chatRequestBodySchema.safeParse(raw);
417
+ if (!parsed.success)
418
+ return jsonResponse({ error: "invalid request body", details: parsed.error.issues }, { status: 400, cors });
419
+ const body = parsed.data;
420
+ const scopedSession = scope(body.session, body.siteId);
421
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
422
+ const result = await runChatPipeline(runtime.pipelineCtx, {
423
+ ...body,
424
+ session: scopedSession
425
+ });
426
+ return jsonResponse(result.payload, { status: result.code, cors });
427
+ }
428
+ if (request.method === "POST" && path === "/chat/stream") {
429
+ const runtime = await getRuntime();
430
+ await runtime.ready;
431
+ let raw;
432
+ try {
433
+ raw = await request.json();
434
+ }
435
+ catch {
436
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
437
+ }
438
+ const parsed = chatRequestBodySchema.safeParse(raw);
439
+ if (!parsed.success)
440
+ return jsonResponse({ error: "invalid request body", details: parsed.error.issues }, { status: 400, cors });
441
+ const body = parsed.data;
442
+ const scoped = { ...body, session: scope(body.session, body.siteId) };
443
+ await runtime.bootstrapCache.ensure(scoped.session, runtime.adapter, runtime.log);
444
+ const encoder = new TextEncoder();
445
+ const stream = new ReadableStream({
446
+ async start(controller) {
447
+ const emit = (event) => {
448
+ try {
449
+ controller.enqueue(encoder.encode(formatSseFrame(event)));
450
+ }
451
+ catch { /* controller closed early (client disconnected) */ }
452
+ };
453
+ // SSE retry hint (60s) — matches the Fastify route's behavior.
454
+ try {
455
+ controller.enqueue(encoder.encode("retry: 60000\n\n"));
456
+ }
457
+ catch { /* */ }
458
+ try {
459
+ await runChatStream(runtime.pipelineCtx, scoped, { emit, signal: request.signal });
460
+ }
461
+ finally {
462
+ try {
463
+ controller.close();
464
+ }
465
+ catch { /* */ }
466
+ }
467
+ },
468
+ cancel() {
469
+ // Client disconnect — request.signal fires automatically, and
470
+ // runChatPipeline observes it. Nothing else to do here.
471
+ }
472
+ });
473
+ return new Response(stream, { status: 200, headers: SSE_HEADERS(cors) });
474
+ }
475
+ // ---- Resumable streaming triplet -----------------------------------
476
+ // POST /chat/start → allocate streamId
477
+ // GET /chat/stream → run the pipeline (or replay+subscribe on reconnect)
478
+ // POST /chat/cancel → abort an active run
479
+ if (request.method === "POST" && path === "/chat/start") {
480
+ const runtime = await getRuntime();
481
+ await runtime.ready;
482
+ let raw;
483
+ try {
484
+ raw = await request.json();
485
+ }
486
+ catch {
487
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
488
+ }
489
+ const parsed = chatRequestBodySchema.safeParse(raw);
490
+ if (!parsed.success)
491
+ return jsonResponse({ error: "invalid request body", details: parsed.error.issues }, { status: 400, cors });
492
+ const body = parsed.data;
493
+ if (!body.session)
494
+ return jsonResponse({ error: "session is required" }, { status: 400, cors });
495
+ const origin = request.headers.get("origin") ?? "*";
496
+ try {
497
+ const entry = runtime.resumableStore.allocate({
498
+ body,
499
+ session: body.session,
500
+ siteId: body.siteId ?? "",
501
+ origin
502
+ });
503
+ return jsonResponse({ streamId: entry.streamId }, { status: 200, cors });
504
+ }
505
+ catch (err) {
506
+ if (err instanceof TooManyPendingStreamsError) {
507
+ return jsonResponse({ error: "Too many pending streams for this session" }, { status: 429, cors });
508
+ }
509
+ throw err;
510
+ }
511
+ }
512
+ if (request.method === "POST" && path === "/chat/cancel") {
513
+ const runtime = await getRuntime();
514
+ let raw;
515
+ try {
516
+ raw = await request.json();
517
+ }
518
+ catch {
519
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
520
+ }
521
+ const body = (raw ?? {});
522
+ const result = runtime.resumableStore.cancel(body);
523
+ if (result.status === "not_found")
524
+ return jsonResponse({ status: "not_found" }, { status: 404, cors });
525
+ if (result.status === "already_terminal")
526
+ return jsonResponse({ status: "already_terminal" }, { status: 200, cors });
527
+ return jsonResponse({ status: "cancel_requested" }, { status: 200, cors });
528
+ }
529
+ if (request.method === "GET" && path === "/chat/stream") {
530
+ const runtime = await getRuntime();
531
+ await runtime.ready;
532
+ const streamId = url.searchParams.get("streamId");
533
+ const afterSeqRaw = url.searchParams.get("afterSeq");
534
+ const afterSeq = afterSeqRaw === null ? 0 : Number(afterSeqRaw) || 0;
535
+ const isReconnect = afterSeqRaw !== null;
536
+ const reqOrigin = request.headers.get("origin") ?? "*";
537
+ if (!streamId) {
538
+ return jsonResponse({ error: "streamId query param required (use POST /chat/start to allocate one)" }, { status: 400, cors });
539
+ }
540
+ const entry = runtime.resumableStore.get(streamId);
541
+ if (!entry)
542
+ return jsonResponse({ error: "Stream context expired or not found" }, { status: 410, cors });
543
+ // Origin check
544
+ if (entry.origin !== "*" && reqOrigin !== "*" && entry.origin !== reqOrigin) {
545
+ return jsonResponse({ error: "Origin mismatch" }, { status: 403, cors });
546
+ }
547
+ const encoder = new TextEncoder();
548
+ // Reconnect: replay + subscribe (don't kick off a new run)
549
+ if (isReconnect) {
550
+ const sseStream = new ReadableStream({
551
+ start(controller) {
552
+ try {
553
+ controller.enqueue(encoder.encode("retry: 60000\n\n"));
554
+ }
555
+ catch { /* */ }
556
+ const subscriber = {
557
+ emit: (envelope) => {
558
+ try {
559
+ controller.enqueue(encoder.encode(formatSseFrame(envelope)));
560
+ }
561
+ catch { /* */ }
562
+ },
563
+ close: () => { try {
564
+ controller.close();
565
+ }
566
+ catch { /* */ } }
567
+ };
568
+ const unsubscribe = runtime.resumableStore.subscribe(streamId, subscriber, afterSeq);
569
+ request.signal.addEventListener("abort", () => unsubscribe());
570
+ },
571
+ cancel() { }
572
+ });
573
+ return new Response(sseStream, { status: 200, headers: SSE_HEADERS(cors) });
574
+ }
575
+ // First connection: only valid for pending streams
576
+ if (entry.state === "active")
577
+ return jsonResponse({ error: "Pipeline already running" }, { status: 409, cors });
578
+ if (isTerminalState(entry.state))
579
+ return jsonResponse({ error: "Stream already completed" }, { status: 410, cors });
580
+ const scoped = { ...entry.body, session: scope(entry.body.session, entry.body.siteId) };
581
+ await runtime.bootstrapCache.ensure(scoped.session, runtime.adapter, runtime.log);
582
+ const sseStream = new ReadableStream({
583
+ async start(controller) {
584
+ try {
585
+ controller.enqueue(encoder.encode("retry: 60000\n\n"));
586
+ }
587
+ catch { /* */ }
588
+ const subscriber = {
589
+ emit: (envelope) => {
590
+ try {
591
+ controller.enqueue(encoder.encode(formatSseFrame(envelope)));
592
+ }
593
+ catch { /* */ }
594
+ },
595
+ close: () => { try {
596
+ controller.close();
597
+ }
598
+ catch { /* */ } }
599
+ };
600
+ // Subscribe BEFORE starting the pipeline so we don't miss early events.
601
+ const unsubscribe = runtime.resumableStore.subscribe(streamId, subscriber, 0);
602
+ request.signal.addEventListener("abort", () => unsubscribe());
603
+ await runResumableChatStream(runtime.pipelineCtx, runtime.resumableStore, streamId, scoped);
604
+ // closeAllSubscribers in runResumableChatStream closes the controller.
605
+ },
606
+ cancel() { }
607
+ });
608
+ return new Response(sseStream, { status: 200, headers: SSE_HEADERS(cors) });
609
+ }
610
+ // ---- Variations ----------------------------------------------------
611
+ /*
612
+ * "Show me three ways to word this hero." Both halves answered 405 here,
613
+ * so on an embedded site the Variations panel opened onto an error — and
614
+ * the editor renders that control unconditionally, off no capability flag.
615
+ *
616
+ * The two paths share everything up to the response because they share
617
+ * every way of being wrong: same body, same scoping, same bootstrap. Only
618
+ * the last step differs, and splitting them earlier is what let the
619
+ * Fastify copy validate one of them slightly differently for a while.
620
+ *
621
+ * `scopeVariationSession` gets this mount's own siteId rather than the
622
+ * body's for the same reason every route here calls `scope()`: the
623
+ * bootstrap below fills exactly one key and the pipeline has to read the
624
+ * one that was filled.
625
+ */
626
+ if (request.method === "POST" && (path === "/chat/variations" || path === "/chat/variations/stream")) {
627
+ const runtime = await getRuntime();
628
+ await runtime.ready;
629
+ let raw;
630
+ try {
631
+ raw = await request.json();
632
+ }
633
+ catch {
634
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
635
+ }
636
+ const parsed = parseVariationRequest(raw);
637
+ if (!parsed.ok)
638
+ return actionResponse(parsed.result, cors);
639
+ const session = scopeVariationSession(parsed.body, effectiveSiteId);
640
+ await runtime.bootstrapCache.ensure(session, runtime.adapter, runtime.log);
641
+ if (path === "/chat/variations") {
642
+ return actionResponse(await variationsAction(runtime.pipelineCtx, parsed.body, session), cors);
643
+ }
644
+ const encoder = new TextEncoder();
645
+ const stream = new ReadableStream({
646
+ async start(controller) {
647
+ const emit = (frame) => {
648
+ try {
649
+ controller.enqueue(encoder.encode(formatVariationFrame(frame)));
650
+ }
651
+ catch { /* controller closed early (client disconnected) */ }
652
+ };
653
+ try {
654
+ // Never rejects: the action turns a thrown pipeline into an
655
+ // `error` frame, because the 200 was committed with the headers.
656
+ await variationsStreamAction(runtime.pipelineCtx, parsed.body, session, emit);
657
+ }
658
+ finally {
659
+ try {
660
+ controller.close();
661
+ }
662
+ catch { /* */ }
663
+ }
664
+ }
665
+ });
666
+ return new Response(stream, { status: 200, headers: SSE_HEADERS(cors) });
667
+ }
668
+ // ---- Library-mode publish ------------------------------------------
669
+ // Minimal: takes the current draft pages for {session, siteId} and hands
670
+ // them to the configured adapter's onPublish() if present. No git/deploy
671
+ // wiring — that lives in apps/orchestrator. The adapter IS the destination.
672
+ if (request.method === "POST" && path === "/publish") {
673
+ const runtime = await getRuntime();
674
+ await runtime.ready;
675
+ let raw;
676
+ try {
677
+ raw = await request.json();
678
+ }
679
+ catch {
680
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
681
+ }
682
+ const body = (raw ?? {});
683
+ const scopedSession = scope(body.session, body.siteId);
684
+ /*
685
+ * Seed the draft before reading it. `onPublish(pages)` is a snapshot
686
+ * contract, and under a snapshot contract "this process has never loaded
687
+ * the draft" and "the user deleted every page" are the same request:
688
+ * both arrive as `[]`. A cold publish therefore asked the adapter to
689
+ * make the site match nothing. PBA survived it only because its writer
690
+ * computes patches and found none; an adapter that reads the contract
691
+ * literally would empty the CMS.
692
+ *
693
+ * This narrows the window rather than closing it — `ensure` swallows a
694
+ * failing `getPages()` by design, so an adapter that is down still
695
+ * leaves an empty draft here. The structural answer is a diff-shaped
696
+ * publish (LM-04).
697
+ */
698
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
699
+ const pages = getSessionPages(scopedSession);
700
+ const slugs = pages.map((p) => p.slug);
701
+ /*
702
+ * Every exit below records what happened, because the alternative was a
703
+ * lie by omission: `publishStatusBySession` was written in exactly one
704
+ * place, the standalone server's publishing route, so in library mode
705
+ * `GET /publish/status` answered "no publish status for session" forever
706
+ * rather than only before the first publish, and `/publish/log` was
707
+ * always empty. An agent reads that 404 as a broken session.
708
+ *
709
+ * The row is born resolved, which the Vercel path cannot do and this one
710
+ * must. There, a build is still running when POST returns, so the row
711
+ * says "triggered" and a later `GET /publish/status` is the only thing
712
+ * that ever learns the outcome. Here the adapter's `onPublish` has
713
+ * already written or thrown by the time control reaches this line —
714
+ * there is no deployment to poll, so a row left "triggered" would be a
715
+ * promise of news that never arrives. `vercelState` is not a claim about
716
+ * Vercel; it is the token `publishStatusFromVercelState` reads, and the
717
+ * way the other synchronous targets (site-contract, git) already say
718
+ * "finished". A later poll is harmless: it only matures rows still
719
+ * marked "triggered".
720
+ *
721
+ * The summary is deliberately diff-free. The standalone route can afford
722
+ * `loadPublishedForDiff` before publishing; here that would be a second
723
+ * `adapter.getPages()` on the publish path — for PBA, 45 sequential
724
+ * Sanity reads (PI-08) bought for one line of prose.
725
+ */
726
+ const record = (ok, summary, error) => {
727
+ const now = new Date().toISOString();
728
+ const target = `cms:${runtime.adapter?.id ?? "none"}`;
729
+ const tracker = {
730
+ session: scopedSession,
731
+ status: ok ? "triggered" : "failed",
732
+ startedAt: now,
733
+ updatedAt: now,
734
+ slugs,
735
+ vercelState: ok ? "READY" : "ERROR",
736
+ deployResponse: target
737
+ };
738
+ publishStatusBySession.set(scopedSession, tracker);
739
+ try {
740
+ pushPublishLogEntry({
741
+ session: scopedSession,
742
+ siteId: normalizeSiteId(effectiveSiteId ?? body.siteId),
743
+ target,
744
+ status: ok ? "success" : "failed",
745
+ summary,
746
+ pageCount: pages.length,
747
+ slugs,
748
+ ...(error ? { error } : {})
749
+ });
750
+ }
751
+ catch (logErr) {
752
+ runtime.log.warn({ session: scopedSession, err: logErr instanceof Error ? logErr.message : String(logErr) }, "library-publish: failed to record publish-log row");
753
+ }
754
+ };
755
+ if (!runtime.adapter?.onPublish) {
756
+ record(true, "Nothing written — the adapter has no onPublish");
757
+ return jsonResponse({ ok: true, written: false, count: pages.length, reason: "adapter has no onPublish; publish is a no-op" }, { status: 200, cors });
758
+ }
759
+ const config = getSiteConfig(scopedSession);
760
+ /*
761
+ * Hand the adapter the baseline it needs to diff.
762
+ *
763
+ * `onPublish(pages)` alone is a snapshot contract, and a snapshot is not
764
+ * invertible: every CMS read is a projection, so writing the projection
765
+ * back replaces an asset reference with a URL and a document reference
766
+ * with a dead href. A publisher has to compare against what it read, and
767
+ * it cannot compare against nothing.
768
+ *
769
+ * This is the copy the bootstrap already took, not a fresh read — a
770
+ * second `getPages()` here is 45 sequential Sanity calls on the
771
+ * integration that motivated it. It is therefore absent after a restart
772
+ * that reloaded the draft from SQLite, which is why the contract says to
773
+ * treat undefined as "no baseline" and never as "the site was empty".
774
+ */
775
+ const published = runtime.bootstrapCache.baselineFor(scopedSession) ?? undefined;
776
+ const context = body.assets || published
777
+ ? { ...(body.assets ? { assets: body.assets } : {}), ...(published ? { published } : {}) }
778
+ : undefined;
779
+ let unsupported = [];
780
+ try {
781
+ const result = await runtime.adapter.onPublish(pages, config, context);
782
+ if (result && typeof result === "object" && Array.isArray(result.unsupported)) {
783
+ unsupported = result.unsupported;
784
+ }
785
+ if (result && typeof result === "object" && result.ok === false) {
786
+ const message = result.error ?? "adapter.onPublish returned not-ok";
787
+ runtime.log.warn({ session: scopedSession, adapter: runtime.adapter.id, error: result.error }, "library-publish: adapter.onPublish() returned not-ok");
788
+ record(false, message, message);
789
+ return jsonResponse({
790
+ ok: false,
791
+ written: false,
792
+ count: pages.length,
793
+ error: message,
794
+ ...(unsupported.length > 0 ? { unsupported } : {})
795
+ }, { status: 502, cors });
796
+ }
797
+ }
798
+ catch (err) {
799
+ const message = err instanceof Error ? err.message : "adapter.onPublish failed";
800
+ runtime.log.warn({ session: scopedSession, adapter: runtime.adapter.id, err: err instanceof Error ? err.stack ?? err.message : String(err) }, "library-publish: adapter.onPublish() threw");
801
+ record(false, message, message);
802
+ return jsonResponse({ ok: false, error: message }, { status: 502, cors });
803
+ }
804
+ /*
805
+ * Success with something to report is the third answer publishing needs.
806
+ * A field-level publisher routinely writes the text and cannot write the
807
+ * image beside it — that is neither a plain success, which claims the
808
+ * whole edit shipped, nor an error, which claims none of it did.
809
+ */
810
+ const summary = buildPublishSummary({ changedSlugs: [], removedSlugs: [], totalPages: pages.length, hasDiff: false });
811
+ record(true, unsupported.length > 0
812
+ ? `${summary} ${unsupported.length} change${unsupported.length === 1 ? "" : "s"} could not be published.`
813
+ : summary);
814
+ return jsonResponse({
815
+ ok: true,
816
+ written: true,
817
+ count: pages.length,
818
+ ...(unsupported.length > 0 ? { unsupported } : {})
819
+ }, { status: 200, cors });
820
+ }
821
+ // The editor polls this on boot to populate its model selector and the
822
+ // planner-source badge. Without it, availableProviders stays empty and the
823
+ // selector falls back to its built-in default (OpenAI), hiding Claude even
824
+ // when only "anthropic" is configured.
825
+ if (request.method === "GET" && path === "/status/planner") {
826
+ const runtime = await getRuntime();
827
+ const providers = runtime.pipelineCtx.availableProviders;
828
+ const hasImageBackend = Boolean(process.env.OPENAI_API_KEY || process.env.GOOGLE_GENAI_API_KEY);
829
+ return jsonResponse({
830
+ plannerSource: providers[0] ?? "demo",
831
+ availableProviders: providers,
832
+ features: {
833
+ googleDrive: false,
834
+ unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
835
+ imageGenerate: hasImageBackend,
836
+ imageGenerateChat: hasImageBackend,
837
+ agentMode: false
838
+ },
839
+ /*
840
+ * The capability probe lives here rather than on `/whoami` because
841
+ * `/whoami` awaits the adapter bootstrap — the cold CMS read that
842
+ * ME-06 exists to hide — and a client asking "what may I do?" must
843
+ * not be made to wait on "what is in the draft?". This route reads
844
+ * no adapter and is not blocked in demo mode.
845
+ */
846
+ capabilities: runtime.capabilities,
847
+ /*
848
+ * Whether a write survives a restart. A library-mode host that cannot
849
+ * open its store still answers every request normally, so without this
850
+ * the only symptom is edits quietly reverting to CMS content one
851
+ * module reload later.
852
+ */
853
+ persistence: persistenceHealth()
854
+ }, { status: 200, cors });
855
+ }
856
+ // ---- Draft read + edit surface (the editor's property panel) -------
857
+ // The editor needs these to display and edit a site's draft content.
858
+ // Each seeds the draft from the adapter first (bootstrapCache.ensure is
859
+ // idempotent) so they work before any chat turn has run.
860
+ // The editor fetches the selected block's props from here (useBlockProps).
861
+ // Without it the property panel can never reach `ready` — it shows the
862
+ // block breadcrumb but no editable fields.
863
+ if (request.method === "GET" && path === "/draft/pages") {
864
+ const runtime = await getRuntime();
865
+ await runtime.ready;
866
+ const session = url.searchParams.get("session") ?? undefined;
867
+ const siteId = url.searchParams.get("siteId") ?? undefined;
868
+ const slug = url.searchParams.get("slug") ?? undefined;
869
+ if (!session || !slug) {
870
+ return jsonResponse({ error: "session and slug are required" }, { status: 400, cors });
871
+ }
872
+ const scopedSession = scope(session, siteId);
873
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
874
+ const page = getPage(scopedSession, slug);
875
+ if (!page)
876
+ return jsonResponse({ error: "not found" }, { status: 404, cors });
877
+ return jsonResponse(structuredClone(page), { status: 200, cors });
878
+ }
879
+ // Page list. Also flips the editor's `hasBootstrapped` gate — until this
880
+ // returns a non-empty list, the property panel never enables its fetch.
881
+ if (request.method === "GET" && path === "/draft/slugs") {
882
+ const runtime = await getRuntime();
883
+ await runtime.ready;
884
+ const session = url.searchParams.get("session") ?? undefined;
885
+ const siteId = url.searchParams.get("siteId") ?? undefined;
886
+ const scopedSession = scope(session, siteId);
887
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
888
+ const pages = getSessionPages(scopedSession);
889
+ return jsonResponse({
890
+ slugs: pages.map((p) => p.slug),
891
+ pages: pages.map((p) => ({
892
+ slug: p.slug,
893
+ // The URL, when the site says it differs from the slug. A client
894
+ // that navigates has to be told; it cannot infer a locale prefix.
895
+ ...(p.meta?.path ? { path: p.meta.path } : {}),
896
+ title: p.title ?? "",
897
+ updatedAt: p.updatedAt ?? "",
898
+ blockCount: p.blocks?.length ?? 0
899
+ })),
900
+ // Which content this is — see orchestrator-core/http/draft-provenance.ts.
901
+ ...describeDraft({
902
+ requestedSiteId: siteId ?? effectiveSiteId,
903
+ scopedSession,
904
+ pageCount: pages.length,
905
+ hasAdapter: Boolean(runtime.adapter)
906
+ })
907
+ }, { status: 200, cors });
908
+ }
909
+ // Editor bootstrap probe. In library mode the adapter is the source of
910
+ // truth (already seeded by ensure), so this is effectively a confirm — we
911
+ // don't clobber the draft with the posted pages.
912
+ if (request.method === "POST" && path === "/draft/bootstrap") {
913
+ const runtime = await getRuntime();
914
+ await runtime.ready;
915
+ let raw;
916
+ try {
917
+ raw = await request.json();
918
+ }
919
+ catch {
920
+ raw = {};
921
+ }
922
+ const body = (raw ?? {});
923
+ const scopedSession = scope(body.session, body.siteId);
924
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
925
+ const pages = getSessionPages(scopedSession);
926
+ return jsonResponse({ status: "bootstrapped", count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
927
+ }
928
+ // Site config — drives the page-level nav-label + SEO fields shown in the
929
+ // property panel when no block is selected.
930
+ if (request.method === "GET" && path === "/draft/site-config") {
931
+ const runtime = await getRuntime();
932
+ await runtime.ready;
933
+ const session = url.searchParams.get("session") ?? undefined;
934
+ const siteId = url.searchParams.get("siteId") ?? undefined;
935
+ if (!session)
936
+ return jsonResponse({ error: "session is required" }, { status: 400, cors });
937
+ const scopedSession = scope(session, siteId);
938
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
939
+ return jsonResponse(getSiteConfig(scopedSession), { status: 200, cors });
940
+ }
941
+ if (request.method === "PUT" && path === "/draft/site-config") {
942
+ const runtime = await getRuntime();
943
+ await runtime.ready;
944
+ let raw;
945
+ try {
946
+ raw = await request.json();
947
+ }
948
+ catch {
949
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
950
+ }
951
+ const body = (raw ?? {});
952
+ if (!body.session)
953
+ return jsonResponse({ error: "session is required" }, { status: 400, cors });
954
+ const parsed = siteConfigSchema.safeParse(body.config);
955
+ if (!parsed.success)
956
+ return jsonResponse({ error: "invalid config", details: parsed.error.issues }, { status: 400, cors });
957
+ const scopedSession = scope(body.session, body.siteId);
958
+ setSiteConfig(scopedSession, parsed.data);
959
+ schedulePersistState(runtime.log);
960
+ return jsonResponse({ status: "ok", config: getSiteConfig(scopedSession) }, { status: 200, cors });
961
+ }
962
+ // Apply operations — the editor's property-panel field edits, page-meta
963
+ // edits, and structural edits all POST here. This is the write path that
964
+ // makes the draft (and therefore the live preview + publish) reflect edits.
965
+ if (request.method === "POST" && path === "/ops") {
966
+ const runtime = await getRuntime();
967
+ await runtime.ready;
968
+ let raw;
969
+ try {
970
+ raw = await request.json();
971
+ }
972
+ catch {
973
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
974
+ }
975
+ const body = (raw ?? {});
976
+ const scopedSession = scope(body.session, body.siteId);
977
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
978
+ const parsedOps = z.array(operationSchema).safeParse(body.ops);
979
+ if (!parsedOps.success)
980
+ return jsonResponse({ error: "invalid ops payload", details: parsedOps.error.issues }, { status: 400, cors });
981
+ if (parsedOps.data.length === 0)
982
+ return jsonResponse({ error: "ops must not be empty" }, { status: 400, cors });
983
+ let manifest;
984
+ if (body.componentsManifest) {
985
+ const payload = typeof body.componentsManifest === "string"
986
+ ? (() => { try {
987
+ return JSON.parse(body.componentsManifest);
988
+ }
989
+ catch {
990
+ return "__invalid__";
991
+ } })()
992
+ : body.componentsManifest;
993
+ if (payload !== "__invalid__") {
994
+ const pm = blockManifestSchema.safeParse(payload);
995
+ if (pm.success)
996
+ manifest = pm.data;
997
+ }
998
+ }
999
+ /*
1000
+ * Validate-only, before anything is snapshotted or bumped. This branch
1001
+ * did not exist: `dryRun` was parsed out of the body by nobody and the
1002
+ * operations were applied for real, so an agent asking "what would this
1003
+ * do?" was told "applied" — the exact opposite of what it asked for.
1004
+ */
1005
+ if (body.dryRun)
1006
+ return actionResponse(await opsDryRunAction(scopedSession, parsedOps.data, manifest), cors);
1007
+ // Snapshot touched pages for undo + verify they exist.
1008
+ const snapshots = new Map();
1009
+ const createPageSlugs = [];
1010
+ for (const op of parsedOps.data) {
1011
+ if (op.op === "create_page") {
1012
+ createPageSlugs.push(op.page.slug);
1013
+ continue;
1014
+ }
1015
+ if (op.op === "update_site_config")
1016
+ continue;
1017
+ if (!("pageSlug" in op) || typeof op.pageSlug !== "string")
1018
+ continue;
1019
+ if (snapshots.has(op.pageSlug))
1020
+ continue;
1021
+ const current = getPage(scopedSession, op.pageSlug);
1022
+ if (!current)
1023
+ return jsonResponse({ error: `page not found: ${op.pageSlug}` }, { status: 404, cors });
1024
+ snapshots.set(op.pageSlug, current);
1025
+ }
1026
+ // Pre-apply block types: a removed block cannot be named afterwards.
1027
+ const blockTypeBeforeApply = (slug, blockId) => snapshots.get(slug)?.blocks?.find((b) => b.id === blockId)?.type ??
1028
+ getPage(scopedSession, slug)?.blocks?.find((b) => b.id === blockId)?.type;
1029
+ try {
1030
+ const applyResult = await applyOpsAtomically(scopedSession, parsedOps.data, { componentsManifest: manifest });
1031
+ for (const [slug, snapshot] of snapshots)
1032
+ pushUndo(scopedSession, slug, snapshot);
1033
+ for (const slug of createPageSlugs)
1034
+ pushUndo(scopedSession, slug, null);
1035
+ const firstSlugOp = parsedOps.data.find((op) => "pageSlug" in op && typeof op.pageSlug === "string");
1036
+ const firstSlug = firstSlugOp && "pageSlug" in firstSlugOp && typeof firstSlugOp.pageSlug === "string" ? firstSlugOp.pageSlug : undefined;
1037
+ const updatedSlug = firstSlug ? pickUpdatedSlug(scopedSession, firstSlug, parsedOps.data) : undefined;
1038
+ if (firstSlug) {
1039
+ pushRecentEdit(scopedSession, { slug: updatedSlug ?? firstSlug, summary: "Applied operations.", ops: parsedOps.data });
1040
+ }
1041
+ const previewVersion = bumpVersion(scopedSession);
1042
+ const versionEntrySlug = updatedSlug ?? firstSlug ?? "/";
1043
+ const versionSnapshot = getPage(scopedSession, versionEntrySlug);
1044
+ pushVersionEntry(scopedSession, {
1045
+ version: previewVersion,
1046
+ slug: versionEntrySlug,
1047
+ summary: "Applied operations.",
1048
+ opTypes: parsedOps.data.map((op) => op.op),
1049
+ opCount: parsedOps.data.length,
1050
+ source: "direct",
1051
+ snapshot: versionSnapshot ? structuredClone(versionSnapshot) : null
1052
+ });
1053
+ schedulePersistState(runtime.log);
1054
+ return jsonResponse({
1055
+ status: "applied",
1056
+ summary: "Applied operations.",
1057
+ changes: describeAppliedOps(parsedOps.data, {
1058
+ getBlockType: blockTypeBeforeApply,
1059
+ skippedOps: applyResult.skippedOps
1060
+ }),
1061
+ mentionedSlugs: collectMentionedSlugsFromOps(parsedOps.data, updatedSlug ?? firstSlug),
1062
+ previewVersion,
1063
+ focusBlockId: pickFocusBlockId(parsedOps.data),
1064
+ updatedSlug,
1065
+ /*
1066
+ * A duplicated page's blocks get fresh ids, and this table is the
1067
+ * only place they are reported. Without it a caller that duplicates
1068
+ * a page cannot address the copy's blocks without re-reading it —
1069
+ * and library mode was throwing the whole apply result away.
1070
+ */
1071
+ ...(applyResult.duplicatedPages.length > 0
1072
+ ? { duplicatedPages: applyResult.duplicatedPages }
1073
+ : {}),
1074
+ ...persistenceWarning()
1075
+ }, { status: 200, cors });
1076
+ }
1077
+ catch (error) {
1078
+ const reason = toErrorDetail(error);
1079
+ return jsonResponse({ error: reason, errorCode: classifyGuardrailError(reason) }, { status: 400, cors });
1080
+ }
1081
+ }
1082
+ /*
1083
+ * ---- History: undo, redo, the version log --------------------------
1084
+ *
1085
+ * `/ops` above has always called `pushUndo`, so the stack was being kept
1086
+ * correctly and nothing could pop it: the editor shows an Undo button
1087
+ * unconditionally, and pressing it answered "POST /history/undo not
1088
+ * handled by createOrchestrator()".
1089
+ *
1090
+ * The bodies are the same functions the Fastify app calls, so undo means
1091
+ * the same thing in both. See orchestrator-core/http/history-actions.ts.
1092
+ */
1093
+ if (request.method === "GET" && path === "/history/status") {
1094
+ const runtime = await getRuntime();
1095
+ await runtime.ready;
1096
+ const query = Object.fromEntries(url.searchParams);
1097
+ return actionResponse(historyStatus(query), cors);
1098
+ }
1099
+ if (request.method === "GET" && path === "/history/log") {
1100
+ const runtime = await getRuntime();
1101
+ await runtime.ready;
1102
+ const query = Object.fromEntries(url.searchParams);
1103
+ return actionResponse(historyLog(query), cors);
1104
+ }
1105
+ if (request.method === "POST" && (path === "/history/undo" || path === "/history/redo" || path === "/history/restore")) {
1106
+ const runtime = await getRuntime();
1107
+ await runtime.ready;
1108
+ let raw;
1109
+ try {
1110
+ raw = await request.json();
1111
+ }
1112
+ catch {
1113
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1114
+ }
1115
+ const body = (raw ?? {});
1116
+ // The session has to exist before its history can be read, exactly as in
1117
+ // /ops — a cold library-mode process has no draft until this runs.
1118
+ const scoped = scope(body.session, body.siteId);
1119
+ await runtime.bootstrapCache.ensure(scoped, runtime.adapter, runtime.log);
1120
+ const action = path === "/history/undo" ? historyUndoAction
1121
+ : path === "/history/redo" ? historyRedoAction
1122
+ : null;
1123
+ if (action)
1124
+ return actionResponse(action(body, runtime.log), cors);
1125
+ return actionResponse(historyRestoreAction(body, runtime.log), cors);
1126
+ }
1127
+ /*
1128
+ * The MCP server's first call. Each install is bound to one
1129
+ * (session, siteId) pair and asks this before a destructive edit, so a
1130
+ * library-mode site that cannot answer it cannot be edited by an agent at
1131
+ * all — the tools refuse before they start.
1132
+ */
1133
+ /*
1134
+ * The block vocabulary of THIS process, which in library mode is the host
1135
+ * site's own — `registerBlocks` ran at runtime build. An MCP agent reads
1136
+ * this instead of its own module scope, which held Avocado's built-ins and
1137
+ * none of the site's custom types.
1138
+ *
1139
+ * No runtime needed for the manifest itself, but the runtime is what calls
1140
+ * `registerBlocks()`, so build it first or a cold process answers with the
1141
+ * built-ins alone — the exact wrong answer this route exists to replace.
1142
+ */
1143
+ /*
1144
+ * The one site this process serves.
1145
+ *
1146
+ * `GET /sites` answered 405 here, so `avocado-list-sites` failed against
1147
+ * every embedded site, and `POST /sites/register` — the only way to tell
1148
+ * the orchestrator where the site is reachable — was missing too, which is
1149
+ * what left `/preview/screenshot` with no `previewUrl` it could ever have
1150
+ * been given.
1151
+ *
1152
+ * A monorepo orchestrator hosts many sites and has a registry to list. A
1153
+ * library-mode one is mounted inside a single site and already knows which,
1154
+ * so this reports that site whether or not anyone registered it: an empty
1155
+ * list would read as "no sites here" about the site asking the question.
1156
+ */
1157
+ if (request.method === "GET" && path === "/sites") {
1158
+ const runtime = await getRuntime();
1159
+ await runtime.ready;
1160
+ const query = Object.fromEntries(url.searchParams);
1161
+ const scoped = scope(query.session, query.siteId);
1162
+ const stored = getSiteConfig(scoped);
1163
+ return jsonResponse({
1164
+ sites: [
1165
+ {
1166
+ id: effectiveSiteId ?? normalizeSiteId(query.siteId),
1167
+ mode: "library",
1168
+ ...(config.previewUrl ? { previewUrl: config.previewUrl } : {}),
1169
+ ...(config.draftPath ? { draftPath: config.draftPath } : {}),
1170
+ // A registration is more specific than a build-time default, so
1171
+ // it goes on top.
1172
+ ...stored
1173
+ }
1174
+ ]
1175
+ }, { cors });
1176
+ }
1177
+ if (request.method === "POST" && path === "/sites/register") {
1178
+ const runtime = await getRuntime();
1179
+ await runtime.ready;
1180
+ let raw;
1181
+ try {
1182
+ raw = await request.json();
1183
+ }
1184
+ catch {
1185
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1186
+ }
1187
+ const body = (raw ?? {});
1188
+ const scoped = scope(body.session, body.siteId);
1189
+ const parsed = siteConfigSchema.safeParse(body.config ?? {});
1190
+ if (!parsed.success) {
1191
+ return jsonResponse({ error: "invalid config", details: parsed.error.issues }, { status: 400, cors });
1192
+ }
1193
+ const extras = {};
1194
+ if (body.name)
1195
+ extras.name = body.name;
1196
+ if (body.purpose)
1197
+ extras.purpose = body.purpose;
1198
+ if (body.previewUrl)
1199
+ extras.previewUrl = body.previewUrl;
1200
+ if (body.draftPath)
1201
+ extras.draftPath = body.draftPath;
1202
+ if (typeof body.port === "number") {
1203
+ extras.port = body.port;
1204
+ if (!body.previewUrl)
1205
+ extras.previewUrl = `http://localhost:${body.port}`;
1206
+ }
1207
+ /*
1208
+ * Merged over what is already stored, not substituted for it. The
1209
+ * monorepo route replaces the config wholesale, which is survivable there
1210
+ * because registration happens before anything else. Here the same object
1211
+ * also holds everything `update_site_config` writes — the site name, nav
1212
+ * labels, theme overrides — and a later `avocado-register-site` call
1213
+ * setting only a preview URL would silently delete all of it.
1214
+ */
1215
+ const existing = getSiteConfig(scoped);
1216
+ const merged = { ...existing, ...parsed.data, ...extras };
1217
+ const unchanged = JSON.stringify(existing) === JSON.stringify(merged);
1218
+ if (!unchanged) {
1219
+ setSiteConfig(scoped, merged);
1220
+ schedulePersistState(runtime.log);
1221
+ }
1222
+ const siteId = effectiveSiteId ?? normalizeSiteId(body.siteId);
1223
+ return jsonResponse({
1224
+ status: unchanged ? "unchanged" : "registered",
1225
+ siteId,
1226
+ session: scoped,
1227
+ config: { id: siteId, ...merged }
1228
+ }, { cors });
1229
+ }
1230
+ if (request.method === "GET" && path === "/blocks/manifest") {
1231
+ const runtime = await getRuntime();
1232
+ /*
1233
+ * A declared type with no `registerBlock` behind it has no schema, so it
1234
+ * silently does not reach the manifest — the site asks for fourteen blocks
1235
+ * and the agent is shown eleven, with nothing anywhere saying why. Usually
1236
+ * a typo or a missing import in the site's own `blocks/register.ts`.
1237
+ * Checked here rather than at mount because registration order is not ours
1238
+ * to depend on; by the time anyone asks for the manifest it has settled.
1239
+ */
1240
+ const missing = undeclaredBlockTypes();
1241
+ if (missing.length > 0) {
1242
+ runtime.log.warn({ types: missing }, "Declared block types have no registered schema and are missing from the manifest");
1243
+ }
1244
+ return actionResponse(blocksManifestAction(), cors);
1245
+ }
1246
+ if (request.method === "GET" && path === "/whoami") {
1247
+ const runtime = await getRuntime();
1248
+ await runtime.ready;
1249
+ const query = Object.fromEntries(url.searchParams);
1250
+ await runtime.bootstrapCache.ensure(scope(query.session, query.siteId), runtime.adapter, runtime.log);
1251
+ return actionResponse(whoamiAction(query, url.origin, { hasAdapter: Boolean(runtime.adapter) }), cors);
1252
+ }
1253
+ /*
1254
+ * Publishing status, history and preview.
1255
+ *
1256
+ * The three are read-only and were the loudest gap: the editor renders a
1257
+ * Publish button unconditionally, and the dialog behind it could not say
1258
+ * what would change, whether the last attempt worked, or what had been
1259
+ * published before.
1260
+ *
1261
+ * All three drop `siteId` on the way in. `scope()` has already answered
1262
+ * the question these actions re-ask, and each of them calls
1263
+ * `scopedSessionKey(session, siteId)` again on whatever it is handed — so
1264
+ * passing the caller's raw `siteId` alongside an already-scoped session
1265
+ * scoped it twice, and a caller that sends one (the MCP server and the
1266
+ * editor both always do) read `pba::library::s1`, a key nothing has ever
1267
+ * written to. That was a second, independent cause of the all-pages-
1268
+ * removed diff and of the permanent 404 from /publish/status: fixing only
1269
+ * the missing bootstrap would have seeded `library::s1` while these kept
1270
+ * reading the double-scoped key. `normalizeSiteId(undefined)` is the
1271
+ * legacy id, so the key passes through untouched.
1272
+ */
1273
+ if (request.method === "GET" && path === "/publish/log") {
1274
+ const runtime = await getRuntime();
1275
+ await runtime.ready;
1276
+ const query = Object.fromEntries(url.searchParams);
1277
+ return actionResponse(publishLogAction({ ...query, session: scope(query.session, query.siteId), siteId: undefined }), cors);
1278
+ }
1279
+ if (request.method === "GET" && path === "/publish/status") {
1280
+ const runtime = await getRuntime();
1281
+ await runtime.ready;
1282
+ const query = Object.fromEntries(url.searchParams);
1283
+ return actionResponse(await publishStatusAction({ ...query, session: scope(query.session, query.siteId), siteId: undefined }), cors);
1284
+ }
1285
+ if (request.method === "GET" && path === "/publish/diff") {
1286
+ const runtime = await getRuntime();
1287
+ await runtime.ready;
1288
+ const query = Object.fromEntries(url.searchParams);
1289
+ const scoped = scope(query.session, query.siteId);
1290
+ /*
1291
+ * Both sides of the diff have to be read from the same era. Without this
1292
+ * seed a cold process compares an empty draft against everything the
1293
+ * adapter reports as live, and `computePublishDiff` faithfully calls the
1294
+ * difference a deletion — the "45 pages removed, 0 added, 0 modified"
1295
+ * an agent session reported as a destructive plan.
1296
+ */
1297
+ await runtime.bootstrapCache.ensure(scoped, runtime.adapter, runtime.log);
1298
+ /*
1299
+ * The published side comes from the adapter, not from the monorepo's
1300
+ * `published-content.json` — the CMS is what is actually live for an
1301
+ * embedded site, and `getPages()` is the only thing that knows it.
1302
+ *
1303
+ * `siteConfig` is deliberately omitted rather than passed as null: the
1304
+ * adapter contract has no way to report the live header, so "cannot
1305
+ * tell" is the truth. Passing null would claim the live site has no
1306
+ * header and open every preview with a phantom "header added".
1307
+ */
1308
+ const source = {
1309
+ load: async () => ({
1310
+ /*
1311
+ * `published`, and the word is load-bearing. The session was seeded
1312
+ * from the draft side, so reading the same side here would diff a
1313
+ * working copy against itself and report every pending CMS edit as
1314
+ * already live. An adapter that reads only one perspective answers
1315
+ * the same list to both calls, which is the pre-existing behaviour
1316
+ * and is why `readsDraftPerspective` is reported: it is the only way
1317
+ * a caller can tell "nothing is pending" from "nothing is visible".
1318
+ */
1319
+ pages: runtime.adapter ? await runtime.adapter.getPages({ perspective: "published" }) : []
1320
+ })
1321
+ };
1322
+ return actionResponse(await publishDiffAction({ ...query, session: scoped, siteId: undefined }, runtime.log, source), cors);
1323
+ }
1324
+ /*
1325
+ * Snapshot restore. The git helpers underneath read the repository the
1326
+ * process was started in, which for an embedded site is the host's own
1327
+ * checkout rather than a store of published snapshots — so these answer an
1328
+ * empty list rather than an error, and the panel renders as "nothing to
1329
+ * restore" instead of failing.
1330
+ */
1331
+ if (request.method === "GET" && path === "/restore/snapshots") {
1332
+ const runtime = await getRuntime();
1333
+ await runtime.ready;
1334
+ return actionResponse(await restoreSnapshotsList(Object.fromEntries(url.searchParams)), cors);
1335
+ }
1336
+ if (request.method === "POST" && path === "/restore/snapshot") {
1337
+ const runtime = await getRuntime();
1338
+ await runtime.ready;
1339
+ let raw;
1340
+ try {
1341
+ raw = await request.json();
1342
+ }
1343
+ catch {
1344
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1345
+ }
1346
+ const body = (raw ?? {});
1347
+ const scoped = scope(body.session, body.siteId);
1348
+ await runtime.bootstrapCache.ensure(scoped, runtime.adapter, runtime.log);
1349
+ return actionResponse(await restoreSnapshotApply({ ...body, session: scoped }, runtime.log), cors);
1350
+ }
1351
+ if (request.method === "DELETE" && path === "/restore/snapshot") {
1352
+ const runtime = await getRuntime();
1353
+ await runtime.ready;
1354
+ let raw;
1355
+ try {
1356
+ raw = await request.json();
1357
+ }
1358
+ catch {
1359
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1360
+ }
1361
+ return actionResponse(await restoreSnapshotDelete((raw ?? {})), cors);
1362
+ }
1363
+ /* The Unsplash tab of the image picker. Unconfigured answers 404, not an
1364
+ * empty result set — "no key" and "no matches" are different answers. */
1365
+ if (request.method === "GET" && path === "/unsplash/search") {
1366
+ const runtime = await getRuntime();
1367
+ await runtime.ready;
1368
+ return actionResponse(await unsplashSearchAction(Object.fromEntries(url.searchParams), { log: runtime.log }), cors);
1369
+ }
1370
+ /* Thumbs up / down under every assistant reply. Fired and forgotten by the
1371
+ * editor, so a malformed payload is rejected rather than half-stored. */
1372
+ if (path === "/telemetry/chat/feedback" && (request.method === "POST" || request.method === "GET")) {
1373
+ const runtime = await getRuntime();
1374
+ await runtime.ready;
1375
+ const store = getFeedbackStore(runtime.log);
1376
+ if (request.method === "GET") {
1377
+ return actionResponse(telemetryFeedbackListAction(Object.fromEntries(url.searchParams), store), cors);
1378
+ }
1379
+ let raw;
1380
+ try {
1381
+ raw = await request.json();
1382
+ }
1383
+ catch {
1384
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1385
+ }
1386
+ return actionResponse(telemetryFeedbackSubmitAction(raw, store), cors);
1387
+ }
1388
+ // ---- Image upload (local disk, POC-grade) --------------------------
1389
+ // The editor's ImagePicker "Upload" tab POSTs a multipart form here and
1390
+ // expects `{ url }` back. Bytes land in `imageDir`; the URL points at the
1391
+ // sibling GET route below, relative to this handler's basePath so it
1392
+ // resolves against the host site's own origin. No CDN / transforms — see
1393
+ // docs/image-storage-options.md for the blob-backend swap.
1394
+ /*
1395
+ * An agent's only way to see what it just edited.
1396
+ *
1397
+ * Both of these are reached only by the MCP server, which is why neither
1398
+ * showed up while the editor was the only client library mode was diffed
1399
+ * against — and why the agent's visual feedback loop was missing from
1400
+ * exactly the mode that an integrated site runs.
1401
+ */
1402
+ if (request.method === "POST" && path === "/preview/screenshot") {
1403
+ const runtime = await getRuntime();
1404
+ const params = (await request.json().catch(() => ({})));
1405
+ const scoped = scope(params.session, params.siteId);
1406
+ // The screenshot is of the DRAFT, so the draft has to exist first. A
1407
+ // cold session photographs an empty page and reports success otherwise.
1408
+ await runtime.bootstrapCache.ensure(scoped, runtime.adapter, runtime.log);
1409
+ return actionResponse(await screenshotAction(params, runtime.log, {
1410
+ takeScreenshot: config.screenshot,
1411
+ fallbackPreviewUrl: config.previewUrl,
1412
+ fallbackDraftPath: config.draftPath,
1413
+ // Same key the bootstrap above seeded, and the same one /sites/register
1414
+ // wrote the preview URL under.
1415
+ scopedSession: scoped
1416
+ }), cors);
1417
+ }
1418
+ // ---- Voice input ---------------------------------------------------
1419
+ /*
1420
+ * The mic button reads `features.audioTranscription` off /status/planner,
1421
+ * which reports on the *keys* — so a host that had funded an
1422
+ * `OPENAI_API_KEY` rendered the button and got "not handled" on click.
1423
+ *
1424
+ * The 503 comes before `formData()` deliberately: an unconfigured
1425
+ * deployment should not be made to upload 25MB to be told there is no
1426
+ * provider. After that the two transports diverge only in how they read
1427
+ * the bytes — Fastify streams the part and aborts mid-upload at the cap,
1428
+ * this buffers and checks afterwards — and agree on every answer, because
1429
+ * both ask the same validator.
1430
+ */
1431
+ if (request.method === "POST" && path === "/audio/transcribe") {
1432
+ const unavailable = transcriptionUnavailable();
1433
+ if (unavailable)
1434
+ return actionResponse(unavailable, cors);
1435
+ let form;
1436
+ try {
1437
+ form = await request.formData();
1438
+ }
1439
+ catch {
1440
+ return jsonResponse({ error: "expected multipart/form-data body" }, { status: 400, cors });
1441
+ }
1442
+ const file = form.get("audio") ?? form.get("file");
1443
+ if (!(file instanceof File)) {
1444
+ return jsonResponse({ error: "missing 'audio' file field" }, { status: 400, cors });
1445
+ }
1446
+ const bytes = new Uint8Array(await file.arrayBuffer());
1447
+ const invalid = validateAudioInput({ mimeType: file.type, byteLength: bytes.byteLength });
1448
+ if (invalid)
1449
+ return actionResponse(invalid, cors);
1450
+ return actionResponse(await transcribeAudioAction({ bytes, mimeType: file.type, filename: file.name || undefined }), cors);
1451
+ }
1452
+ // ---- AI image generation -------------------------------------------
1453
+ /*
1454
+ * Both of these write bytes, and where those bytes land is the whole
1455
+ * reason `ImageStore` exists. The core helpers resolve a directory and a
1456
+ * public origin from the environment, which is right for the standalone
1457
+ * server and wrong here: an embedded mount has its own `imageDir` and
1458
+ * serves the files back under its own `basePath`. Left on the default,
1459
+ * generation would appear to work and hand the editor a URL that 404s —
1460
+ * the failure that is hardest to see from a test, since every assertion
1461
+ * about the action itself still passes.
1462
+ */
1463
+ const imageStore = () => fileImageStore({ dir: imageDir, publicBaseUrl: `${basePath}/generated-images` });
1464
+ if (request.method === "POST" && path === "/image/generate") {
1465
+ const runtime = await getRuntime();
1466
+ const body = ((await request.json().catch(() => ({}))) ?? {});
1467
+ return actionResponse(await generateImageAction(body, { log: runtime.log, store: imageStore() }), cors);
1468
+ }
1469
+ if (request.method === "POST" && path === "/image/generate/chat") {
1470
+ const runtime = await getRuntime();
1471
+ const body = ((await request.json().catch(() => ({}))) ?? {});
1472
+ /*
1473
+ * Validated before the branch, not inside each arm: once the streaming
1474
+ * arm writes SSE headers the status code is spent, and a missing Google
1475
+ * key would have to be reported as an `error` frame the client would
1476
+ * need a special case for. As a 503 it reuses the path every other
1477
+ * unconfigured route already takes.
1478
+ */
1479
+ const invalid = validateImageChatRequest(body);
1480
+ if (invalid)
1481
+ return actionResponse(invalid, cors);
1482
+ const deps = { log: runtime.log, store: imageStore() };
1483
+ if (!body.stream)
1484
+ return actionResponse(await imageChatAction(body, deps), cors);
1485
+ const encoder = new TextEncoder();
1486
+ const stream = new ReadableStream({
1487
+ async start(controller) {
1488
+ const emit = (frame) => {
1489
+ try {
1490
+ controller.enqueue(encoder.encode(formatImageChatFrame(frame)));
1491
+ }
1492
+ catch { /* controller closed early (client disconnected) */ }
1493
+ };
1494
+ try {
1495
+ await imageChatStreamAction(body, deps, emit);
1496
+ }
1497
+ finally {
1498
+ try {
1499
+ controller.close();
1500
+ }
1501
+ catch { /* */ }
1502
+ }
1503
+ }
1504
+ });
1505
+ return new Response(stream, { status: 200, headers: SSE_HEADERS(cors) });
1506
+ }
1507
+ if (request.method === "POST" && path === "/image/interpret") {
1508
+ const runtime = await getRuntime();
1509
+ let form;
1510
+ try {
1511
+ form = await request.formData();
1512
+ }
1513
+ catch {
1514
+ return jsonResponse({ error: "expected multipart/form-data body" }, { status: 400, cors });
1515
+ }
1516
+ // Fastify's copy reads the first file part whatever it is named; a Web
1517
+ // FormData has no such notion, so accept either field name the callers
1518
+ // use rather than making the MCP tool and the composer disagree.
1519
+ const file = form.get("image") ?? form.get("file");
1520
+ if (!(file instanceof File)) {
1521
+ return jsonResponse({ error: "missing 'image' file field" }, { status: 400, cors });
1522
+ }
1523
+ const bytes = Buffer.from(await file.arrayBuffer());
1524
+ return actionResponse(await interpretImageAction({ bytes, byteLength: bytes.byteLength, mimeType: file.type }, { log: runtime.log }), cors);
1525
+ }
1526
+ if (request.method === "POST" && path === "/image/upload") {
1527
+ let form;
1528
+ try {
1529
+ form = await request.formData();
1530
+ }
1531
+ catch {
1532
+ return jsonResponse({ error: "expected multipart/form-data body" }, { status: 400, cors });
1533
+ }
1534
+ const file = form.get("image");
1535
+ if (!(file instanceof File)) {
1536
+ return jsonResponse({ error: "missing 'image' file field" }, { status: 400, cors });
1537
+ }
1538
+ const ext = MIME_TO_EXT[file.type];
1539
+ if (!ext) {
1540
+ return jsonResponse({ error: `unsupported image type: ${file.type || "unknown"}` }, { status: 415, cors });
1541
+ }
1542
+ if (file.size > MAX_UPLOAD_BYTES) {
1543
+ return jsonResponse({ error: `image exceeds ${MAX_UPLOAD_BYTES} byte limit` }, { status: 413, cors });
1544
+ }
1545
+ const fileName = `upload_${Date.now()}_${randomUUID().slice(0, 8)}.${ext}`;
1546
+ try {
1547
+ await mkdir(imageDir, { recursive: true });
1548
+ await writeFile(resolve(imageDir, fileName), Buffer.from(await file.arrayBuffer()));
1549
+ }
1550
+ catch (err) {
1551
+ return jsonResponse({ error: "image upload failed", detail: err instanceof Error ? err.message : String(err) }, { status: 500, cors });
1552
+ }
1553
+ return jsonResponse({ url: `${basePath}/generated-images/${fileName}`, bytes: file.size, mimeType: file.type }, { status: 200, cors });
1554
+ }
1555
+ // Serve a previously-uploaded image off local disk. `basename` collapses
1556
+ // any path-traversal attempt to a bare filename before it touches the FS.
1557
+ if (request.method === "GET" && path.startsWith("/generated-images/")) {
1558
+ const fileName = basename(path.slice("/generated-images/".length));
1559
+ if (!fileName || !/^[A-Za-z0-9._-]+$/.test(fileName)) {
1560
+ return jsonResponse({ error: "invalid file name" }, { status: 400, cors });
1561
+ }
1562
+ const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
1563
+ const mime = EXT_TO_MIME[ext];
1564
+ if (!mime)
1565
+ return jsonResponse({ error: "unsupported file type" }, { status: 415, cors });
1566
+ let bytes;
1567
+ try {
1568
+ bytes = await readFile(resolve(imageDir, fileName));
1569
+ }
1570
+ catch {
1571
+ return jsonResponse({ error: "not found" }, { status: 404, cors });
1572
+ }
1573
+ return new Response(new Uint8Array(bytes), {
1574
+ status: 200,
1575
+ headers: {
1576
+ "content-type": mime,
1577
+ "cache-control": "public, max-age=31536000, immutable",
1578
+ ...cors
1579
+ }
1580
+ });
1581
+ }
1582
+ return jsonResponse({
1583
+ error: `Method ${request.method} ${path} not handled by createOrchestrator()`,
1584
+ hint: `Supported: ${SUPPORTED_ROUTES.join(", ")}. ` +
1585
+ "Use the Fastify orchestrator at apps/orchestrator for the full surface (sites, the session directory, the agent surface, etc.)."
1586
+ }, { status: 405, cors });
1587
+ };
1588
+ handler.dispose = async () => {
1589
+ if (!runtimePromise)
1590
+ return;
1591
+ try {
1592
+ const runtime = await runtimePromise;
1593
+ runtime.resumableStore.dispose();
1594
+ }
1595
+ catch { /* runtime never built; nothing to dispose */ }
1596
+ runtimePromise = null;
1597
+ };
1598
+ return handler;
1599
+ }