@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.
- package/dist/agent/sites-agent-context.js +3 -2
- package/dist/agent/sites-agent-shared.js +1 -0
- package/dist/chat/anthropic-planner.js +3 -3
- package/dist/chat/chat-pipeline.js +122 -20
- package/dist/chat/gemini-planner.js +3 -3
- package/dist/chat/planner.js +7 -5
- package/dist/chat/prompts.js +6 -1
- package/dist/cms/adapter.d.ts +159 -1
- package/dist/cms/adapter.js +19 -1
- package/dist/cms/bootstrap.d.ts +46 -1
- package/dist/cms/bootstrap.js +126 -2
- package/dist/cms/index.d.ts +3 -2
- package/dist/cms/index.js +2 -1
- package/dist/errors.d.ts +9 -1
- package/dist/handler/auth.d.ts +79 -0
- package/dist/handler/auth.js +113 -0
- package/dist/handler/create-orchestrator.d.ts +205 -0
- package/dist/handler/create-orchestrator.js +1599 -0
- package/dist/http/access-tokens.d.ts +58 -0
- package/dist/http/access-tokens.js +161 -0
- package/dist/http/audio-actions.d.ts +121 -0
- package/dist/http/audio-actions.js +248 -0
- package/dist/http/blocks-actions.d.ts +31 -0
- package/dist/http/blocks-actions.js +31 -0
- package/dist/http/draft-provenance.d.ts +68 -0
- package/dist/http/draft-provenance.js +101 -0
- package/dist/http/history-actions.d.ts +58 -0
- package/dist/http/history-actions.js +169 -0
- package/dist/http/image-generate-actions.d.ts +268 -0
- package/dist/http/image-generate-actions.js +546 -0
- package/dist/http/ops-actions.d.ts +51 -0
- package/dist/http/ops-actions.js +79 -0
- package/dist/http/publish-actions.d.ts +153 -0
- package/dist/http/publish-actions.js +323 -0
- package/dist/http/restore-actions.d.ts +67 -0
- package/dist/http/restore-actions.js +145 -0
- package/dist/http/screenshot-actions.d.ts +108 -0
- package/dist/http/screenshot-actions.js +181 -0
- package/dist/http/session-actions.d.ts +35 -0
- package/dist/http/session-actions.js +98 -0
- package/dist/http/telemetry-feedback-actions.d.ts +53 -0
- package/dist/http/telemetry-feedback-actions.js +68 -0
- package/dist/http/unsplash-actions.d.ts +64 -0
- package/dist/http/unsplash-actions.js +81 -0
- package/dist/http/variations-actions.d.ts +102 -0
- package/dist/http/variations-actions.js +104 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +21 -1
- package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
- package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
- package/dist/nlp/deterministic-planner-suggestions.js +37 -11
- package/dist/nlp/plan-normalizer.js +18 -2
- package/dist/ops/ops-engine.js +219 -14
- package/dist/state/session-state.d.ts +56 -1
- package/dist/state/session-state.js +92 -6
- package/dist/state/sqlite-store-singleton.d.ts +22 -0
- package/dist/state/sqlite-store-singleton.js +49 -1
- package/dist/state/sqlite-store.d.ts +5 -0
- package/dist/state/sqlite-store.js +125 -2
- package/dist/telemetry/chat-telemetry.js +6 -1
- 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
|
+
}
|