mockpit 0.0.0-stage → 1.0.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 (82) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +232 -2
  3. package/bin/demoData.js +132 -0
  4. package/bin/initDesign.js +846 -0
  5. package/bin/mockpit.js +1830 -0
  6. package/bin/serveUrl.d.ts +1 -0
  7. package/bin/serveUrl.js +7 -0
  8. package/dist/mcp/server.d.ts +2 -0
  9. package/dist/mcp/server.js +230 -0
  10. package/dist/server/apiViews.d.ts +329 -0
  11. package/dist/server/apiViews.js +135 -0
  12. package/dist/server/app.d.ts +53 -0
  13. package/dist/server/app.js +3112 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +16 -0
  16. package/dist/server/cdn.d.ts +6 -0
  17. package/dist/server/cdn.js +33 -0
  18. package/dist/server/codeRunner.d.ts +5 -0
  19. package/dist/server/codeRunner.js +89 -0
  20. package/dist/server/codeWorker.d.ts +1 -0
  21. package/dist/server/codeWorker.js +196 -0
  22. package/dist/server/designGuide.d.ts +16 -0
  23. package/dist/server/designGuide.js +311 -0
  24. package/dist/server/events.d.ts +53 -0
  25. package/dist/server/events.js +14 -0
  26. package/dist/server/feedbackBatch.d.ts +43 -0
  27. package/dist/server/feedbackBatch.js +98 -0
  28. package/dist/server/icons.d.ts +50 -0
  29. package/dist/server/icons.js +225 -0
  30. package/dist/server/index.d.ts +1 -0
  31. package/dist/server/index.js +64 -0
  32. package/dist/server/kits.d.ts +36 -0
  33. package/dist/server/kits.js +279 -0
  34. package/dist/server/knobs.d.ts +15 -0
  35. package/dist/server/knobs.js +333 -0
  36. package/dist/server/mcpHttp.d.ts +53 -0
  37. package/dist/server/mcpHttp.js +143 -0
  38. package/dist/server/mcpSpec.d.ts +191 -0
  39. package/dist/server/mcpSpec.js +402 -0
  40. package/dist/server/parts.d.ts +31 -0
  41. package/dist/server/parts.js +338 -0
  42. package/dist/server/postMarkdown.d.ts +10 -0
  43. package/dist/server/postMarkdown.js +271 -0
  44. package/dist/server/postSurfaces.d.ts +40 -0
  45. package/dist/server/postSurfaces.js +407 -0
  46. package/dist/server/public.d.ts +5 -0
  47. package/dist/server/public.js +5 -0
  48. package/dist/server/push.d.ts +45 -0
  49. package/dist/server/push.js +234 -0
  50. package/dist/server/richRender.d.ts +15 -0
  51. package/dist/server/richRender.js +392 -0
  52. package/dist/server/run.d.ts +82 -0
  53. package/dist/server/run.js +290 -0
  54. package/dist/server/runApi.d.ts +6 -0
  55. package/dist/server/runApi.js +82 -0
  56. package/dist/server/slots.d.ts +14 -0
  57. package/dist/server/slots.js +71 -0
  58. package/dist/server/sqlStore.d.ts +119 -0
  59. package/dist/server/sqlStore.js +954 -0
  60. package/dist/server/sqliteStorage.d.ts +2 -0
  61. package/dist/server/sqliteStorage.js +90 -0
  62. package/dist/server/surfacePage.d.ts +48 -0
  63. package/dist/server/surfacePage.js +1099 -0
  64. package/dist/server/themes.d.ts +48 -0
  65. package/dist/server/themes.js +305 -0
  66. package/dist/server/types.d.ts +599 -0
  67. package/dist/server/types.js +299 -0
  68. package/dist/workers/public.d.ts +1 -0
  69. package/dist/workers/public.js +3 -0
  70. package/extensions/mockpit.js +703 -0
  71. package/guide/AGENT_SETUP.md +23 -0
  72. package/guide/topics/asks.md +87 -0
  73. package/guide/topics/feedback.md +127 -0
  74. package/guide/topics/html.md +175 -0
  75. package/guide/topics/http.md +76 -0
  76. package/guide/topics/knobs.md +72 -0
  77. package/guide/topics/scripts.md +129 -0
  78. package/guide/topics/surfaces.md +100 -0
  79. package/package.json +165 -4
  80. package/skills/launch-video/SKILL.md +159 -0
  81. package/skills/mockpit/SKILL.md +40 -0
  82. package/viewer/dist/index.html +1922 -0
@@ -0,0 +1,3112 @@
1
+ import { Hono } from "hono";
2
+ import { bodyLimit } from "hono/body-limit";
3
+ import { getCookie, setCookie } from "hono/cookie";
4
+ import { streamSSE } from "hono/streaming";
5
+ import { decodeBase64 } from "./base64.js";
6
+ import { mockDetailView, mockSummaryView, sessionRowView, surfaceRef } from "./apiViews.js";
7
+ import { EventBus } from "./events.js";
8
+ import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
9
+ import { buildFeedbackBatches } from "./feedbackBatch.js";
10
+ import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
11
+ import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
12
+ import { checkKnobs, checkKnobValues, discreteChoices } from "./knobs.js";
13
+ import { diffParts, partsInSurfaces, spliceParts } from "./parts.js";
14
+ import { postToMarkdown } from "./postMarkdown.js";
15
+ import { addHook, addSubscription, isPushSubscription, listHooks, notify, removeHook, vapidKeys, } from "./push.js";
16
+ import { expandSlots, parseSlotTags } from "./slots.js";
17
+ import { registerMcp } from "./mcpHttp.js";
18
+ import { createRunFlow, DEFAULT_RUN_LIMITS } from "./run.js";
19
+ import { RUN_API } from "./runApi.js";
20
+ import { escapeHtml, renderHtmlPage, renderMermaidPage, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
21
+ import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
22
+ import { BUILTIN_ASK_ID, builtinAsk, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, MAX_ASSET_BYTES, newId, openCount, OTHER_ID, projectFromCwd, reservedAgent, slugify, surfacesByteLength, } from "./types.js";
23
+ import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
24
+ const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
25
+ // A held long-poll (watch, the viewer) re-arms rather than pinning a socket
26
+ // for good; proxies and serverless hosts drop longer requests anyway.
27
+ const MAX_WAIT_SECONDS = 230;
28
+ // Hard ceiling on any request body, applied globally. Every write endpoint
29
+ // reads its body with an unbounded `c.req.json()` (and /mcp likewise), so
30
+ // without this a single oversize POST is an out-of-memory flood — and the local
31
+ // default ships with no auth token, so those endpoints are reachable
32
+ // unauthenticated. Sized to clear the largest legitimate body — a base64 asset
33
+ // uploaded over MCP, ~4/3 of the 5 MiB asset cap — while still bounding a flood.
34
+ // The /api/assets route's own 5 MiB streaming cap is stricter and still applies.
35
+ const MAX_BODY_BYTES = 16 * 1024 * 1024;
36
+ // A comment's text and a surface's title both ride the feedback channel back to
37
+ // the agent in feedback batches, re-sent on every poll — so cap them at the
38
+ // edge to keep one oversize value from bloating the agent's context forever.
39
+ const MAX_COMMENT_TEXT = 8000;
40
+ const MAX_TITLE = 500;
41
+ // Ceiling on concurrently-held SSE + long-poll connections. Both are GETs that
42
+ // pin a connection open (the event stream indefinitely, /api/comments?wait up
43
+ // to MAX_WAIT_SECONDS); on a publicRead workspace they're reachable unauthenticated,
44
+ // so without a cap a flood exhausts sockets. One app instance is one workspace
45
+ // (a single Durable Object), and one workspace is one user — so legitimate
46
+ // concurrent holds are small but not tiny: each open viewer tab holds one SSE,
47
+ // and each active agent holds a long-poll (and possibly its own SSE). A
48
+ // multi-agent session with 5 agents plus a few viewer tabs can legitimately
49
+ // reach ~15. 32 clears that with headroom while still bounding a flood on a
50
+ // no-token local workspace — and a real flood is orders of magnitude bigger, so
51
+ // rejecting at 32 vs 16 makes no difference to flood protection, only to
52
+ // legitimate use. Configurable so deployments can tune it and tests can
53
+ // exercise the cap cheaply.
54
+ const DEFAULT_MAX_HOLD_CONNECTIONS = 32;
55
+ // Asset serving policy: only raster images are served inline; everything else
56
+ // (incl. svg, json, text, the octet-stream catch-all) is an attachment, so a
57
+ // top-level open of /a/:id can never execute an uploaded document as a live
58
+ // same-origin script. <img>/fetch ignore Content-Disposition, so embedding
59
+ // keeps working regardless.
60
+ const INLINE_IMAGE_TYPES = new Set([
61
+ "image/png",
62
+ "image/jpeg",
63
+ "image/gif",
64
+ "image/webp",
65
+ "image/avif",
66
+ ]);
67
+ const ATTACH_SAFE_TYPES = new Set([
68
+ "image/svg+xml",
69
+ "application/json",
70
+ "application/x-ndjson",
71
+ "text/plain",
72
+ "text/csv",
73
+ ]);
74
+ function assetServeHeaders(asset) {
75
+ if (INLINE_IMAGE_TYPES.has(asset.contentType)) {
76
+ return { contentType: asset.contentType, disposition: "inline" };
77
+ }
78
+ const contentType = ATTACH_SAFE_TYPES.has(asset.contentType)
79
+ ? asset.contentType
80
+ : "application/octet-stream";
81
+ const name = (asset.filename || asset.id).replace(/[^\w.-]/g, "_");
82
+ return { contentType, disposition: `attachment; filename="${name}"` };
83
+ }
84
+ // Pick an AssetKind when the caller didn't specify one.
85
+ function inferAssetKind(contentType) {
86
+ return contentType.startsWith("image/") ? "image" : "file";
87
+ }
88
+ const isAssetKind = (v) => v === "image" || v === "file";
89
+ // Docs and onboarding snippets are written against the local default; serve
90
+ // them with the real origin so a deployed instance shows copy-pasteable URLs.
91
+ const LOCAL_ORIGIN = "http://localhost:8228";
92
+ // Newer-than for plain x.y.z strings; prerelease suffixes compare as their
93
+ // base version, and garbage compares as "not newer".
94
+ function versionGt(a, b) {
95
+ const pa = a.split("-")[0].split(".").map(Number);
96
+ const pb = b.split("-")[0].split(".").map(Number);
97
+ for (let i = 0; i < 3; i++) {
98
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
99
+ if (d !== 0)
100
+ return d > 0;
101
+ }
102
+ return false;
103
+ }
104
+ // Latest published version from npm, release notes from the matching GitHub
105
+ // release. Notes are garnish: if GitHub is unreachable the version alone
106
+ // still makes a usable notice.
107
+ async function fetchLatestFromRegistry() {
108
+ const res = await fetch("https://registry.npmjs.org/mockpit/latest");
109
+ if (!res.ok)
110
+ return null;
111
+ const pkg = (await res.json());
112
+ if (typeof pkg.version !== "string")
113
+ return null;
114
+ let notes;
115
+ try {
116
+ const gh = await fetch(`https://api.github.com/repos/fabiogaliano/mockpit/releases/tags/v${pkg.version}`, { headers: { "user-agent": "mockpit", accept: "application/vnd.github+json" } });
117
+ if (gh.ok) {
118
+ const rel = (await gh.json());
119
+ if (typeof rel.body === "string")
120
+ notes = rel.body;
121
+ }
122
+ }
123
+ catch {
124
+ // ignore — see above
125
+ }
126
+ return { version: pkg.version, notes };
127
+ }
128
+ const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
129
+ // In "session" mode only reads addressed by an unguessable id are public: a
130
+ // mock id, a post id (its documents), an asset id, a session's comments/feed.
131
+ // Anything addressed by NAME (projects, mock slugs, the mock list) would let a
132
+ // single shared link enumerate the whole workspace, so it stays private.
133
+ function isPublicReadAllowed(path, mode, query) {
134
+ if (mode === "full")
135
+ return true;
136
+ if (path.startsWith("/project/"))
137
+ return true;
138
+ if (path.startsWith("/s/"))
139
+ return true;
140
+ if (path.startsWith("/a/"))
141
+ return true;
142
+ if (/^\/api\/mocks\/[^/]+(\/export)?$/.test(path)) {
143
+ return !path.startsWith("/api/mocks/recent") && !query.has("project");
144
+ }
145
+ if (path === "/api/comments")
146
+ return true;
147
+ if (path === "/api/events")
148
+ return true;
149
+ if (path === "/api/theme")
150
+ return true;
151
+ if (path === "/api/version")
152
+ return true;
153
+ // A project's kits are named by project, so only the bundled list is public.
154
+ if (path === "/api/kits")
155
+ return !query.has("project");
156
+ return false;
157
+ }
158
+ const ok = (body, status = 200) => ({ status, body });
159
+ const fail = (status, error, extra = {}) => ({
160
+ status,
161
+ body: { error, ...extra },
162
+ });
163
+ const str = (v, max) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
164
+ const MAX_LABEL = 120;
165
+ const MAX_ASKS = 20;
166
+ const MAX_OPTIONS = 20;
167
+ const MAX_DRAFT_COMMENTS = 50;
168
+ const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
169
+ export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, }) {
170
+ const app = new Hono();
171
+ // The scripts topic carries the run API from runApi.ts, its one source.
172
+ if (topics.scripts)
173
+ topics = {
174
+ ...topics,
175
+ scripts: topics.scripts.replace("<!-- run-api -->", `\`\`\`ts\n${RUN_API}\n\`\`\``),
176
+ };
177
+ // `?key=` bootstraps cookie auth, so never let a workspace URL disclose that
178
+ // credential to another origin through an outbound Referer header. Set this
179
+ // before auth so denied and public routes carry the same policy.
180
+ // The origin of the most recent request. A surface document bakes its origin
181
+ // in (CSP, <base>, asset URLs), so the publish-time pre-warm needs one — and a
182
+ // write arrives over the same origin the viewer is about to read from. It is
183
+ // only ever a cache-key input: an origin that turns out to be wrong wastes a
184
+ // warm entry, it can never be served to the wrong caller (renderKey includes it).
185
+ let lastOrigin = null;
186
+ app.use("*", (c, next) => {
187
+ lastOrigin = new URL(c.req.url).origin;
188
+ c.header("Referrer-Policy", "no-referrer");
189
+ return next();
190
+ });
191
+ const bus = new EventBus();
192
+ if (onEvent) {
193
+ bus.subscribe((event) => {
194
+ try {
195
+ onEvent(event);
196
+ }
197
+ catch (err) {
198
+ console.warn("[mockpit] onEvent listener failed", err);
199
+ }
200
+ });
201
+ }
202
+ // Live count of held SSE + long-poll connections, gated by maxHoldConnections.
203
+ // Each holder increments on entry and releases exactly once via a guarded
204
+ // release() wired to every exit (stream abort, request abort, normal return).
205
+ let holdConnections = 0;
206
+ // Only the viewer holds /api/events (watch long-polls), so an attached SSE
207
+ // client means someone has the workspace open.
208
+ let viewerClients = 0;
209
+ const acquireHold = () => {
210
+ if (holdConnections >= maxHoldConnections)
211
+ return false;
212
+ holdConnections++;
213
+ return true;
214
+ };
215
+ const makeRelease = () => {
216
+ let released = false;
217
+ return () => {
218
+ if (released)
219
+ return;
220
+ released = true;
221
+ holdConnections--;
222
+ };
223
+ };
224
+ // Rendered-document cache for /s/:id rich surfaces. Rendering a markdown/code/
225
+ // diff surface runs shiki / @pierre-diffs SSR, which is non-trivial (a big diff
226
+ // is tens of ms + tens of KB), so memoize the finished document string. The
227
+ // key pins everything the output depends on — post id, surface index, the
228
+ // RESOLVED version number, theme, mode — and a version's content is immutable,
229
+ // so a hit is always correct (a post edit bumps the version → a new key).
230
+ // Bounded by BYTES and LRU-evicted: a dropped entry costs a re-render, never
231
+ // correctness. Bounding the entry COUNT was the wrong axis — a document can
232
+ // weigh anything from 2 KB to ~900 KB, so 512 entries could mean 450 MB in an
233
+ // isolate with a 128 MB budget. The DurableObject is single-instance per
234
+ // workspace, so this in-memory cache is authoritative; a multi-instance deploy
235
+ // could back it with KV/Cache API behind the same key without changing callers.
236
+ const MAX_RENDER_CACHE_BYTES = 32 * 1024 * 1024;
237
+ const renderCache = new Map();
238
+ let renderCacheBytes = 0;
239
+ // UTF-16 code units × 2 is the retained JS string size, which is what this
240
+ // bound is protecting — not the transferred UTF-8 length.
241
+ const docBytes = (doc) => doc.length * 2;
242
+ const dropCacheEntry = (key) => {
243
+ const doc = renderCache.get(key);
244
+ if (doc === undefined)
245
+ return;
246
+ renderCacheBytes -= docBytes(doc);
247
+ renderCache.delete(key);
248
+ };
249
+ const clearRenderCache = () => {
250
+ renderCache.clear();
251
+ renderCacheBytes = 0;
252
+ };
253
+ // The response headers every surface document carries, on the cache-first
254
+ // path and the render path alike.
255
+ //
256
+ // The `sandbox` CSP sandboxes the document however it is loaded. The viewer
257
+ // embeds it in an iframe whose `sandbox="allow-scripts"` attribute gives it an
258
+ // opaque origin, but the document is served from the workspace's own origin —
259
+ // so a TOP-LEVEL load (a user opening /s/:id in a new tab, an agent-shared
260
+ // link) would otherwise run the agent's script in the workspace origin, where
261
+ // it could reach same-origin storage or window.open('/') the real viewer. A
262
+ // `sandbox` CSP can only be set as a response header (not the meta tag the
263
+ // page carries), and it forces the same opaque-origin sandbox on a direct
264
+ // navigation: allow-scripts so the bridge still runs, but no allow-same-origin,
265
+ // so agent code can never touch the workspace origin. Mirrors the iframe's
266
+ // sandbox flags.
267
+ //
268
+ // Version-pinned + themed requests (what the viewer always sends) are
269
+ // immutable, so they allow long-lived shared caching; an unpinned direct load
270
+ // is not.
271
+ function surfaceDocHeaders(c, immutable) {
272
+ c.header("X-Content-Type-Options", "nosniff");
273
+ c.header("Content-Security-Policy", "sandbox allow-scripts");
274
+ c.header("Cache-Control", immutable ? "public, max-age=31536000, immutable" : "private, no-cache");
275
+ }
276
+ function renderCacheHit(key) {
277
+ const hit = renderCache.get(key);
278
+ if (hit === undefined)
279
+ return undefined;
280
+ // refresh LRU recency
281
+ renderCache.delete(key);
282
+ renderCache.set(key, hit);
283
+ return hit;
284
+ }
285
+ async function cachedRender(key, build) {
286
+ const hit = renderCacheHit(key);
287
+ if (hit !== undefined)
288
+ return hit;
289
+ const doc = await build();
290
+ renderCache.set(key, doc);
291
+ renderCacheBytes += docBytes(doc);
292
+ while (renderCacheBytes > MAX_RENDER_CACHE_BYTES && renderCache.size > 1) {
293
+ const oldest = renderCache.keys().next().value;
294
+ if (oldest === undefined)
295
+ break;
296
+ dropCacheEntry(oldest);
297
+ }
298
+ return doc;
299
+ }
300
+ // Last-resort safety net: any handler that throws (rather than returning a
301
+ // status) becomes a clean JSON 500 instead of leaking a stack or a bare crash.
302
+ // Validation rejects bad input with 4xx before this, so reaching here means an
303
+ // unexpected bug — log it so it isn't swallowed silently.
304
+ app.onError((err, c) => {
305
+ console.error("mockpit: unhandled error", err);
306
+ return c.json({ error: "internal error" }, 500);
307
+ });
308
+ const normalizeBasePath = (value) => {
309
+ if (!value || value === "/")
310
+ return "";
311
+ const withLeading = value.startsWith("/") ? value : `/${value}`;
312
+ let end = withLeading.length;
313
+ while (end > 0 && withLeading.charCodeAt(end - 1) === 47)
314
+ end--;
315
+ return withLeading.slice(0, end);
316
+ };
317
+ const requestBasePath = (request) => normalizeBasePath(typeof basePath === "function" ? basePath(request) : basePath);
318
+ // Cached, fail-silent update lookup: being offline or rate-limited must
319
+ // cost nothing but the absence of the notice. Failures are cached too, so
320
+ // a dead network doesn't retry on every viewer load.
321
+ let updateCache = null;
322
+ async function latestRelease() {
323
+ if (updateCache && Date.now() - updateCache.at < UPDATE_CHECK_TTL_MS)
324
+ return updateCache.value;
325
+ const value = await (fetchLatestRelease ?? fetchLatestFromRegistry)().catch(() => null);
326
+ updateCache = { at: Date.now(), value };
327
+ return value;
328
+ }
329
+ // --- shared flows (used by both the REST API and the MCP endpoint) ---
330
+ // User comments the agent has not seen yet ride along on its next write, so
331
+ // agents hear feedback without blocking on the long-poll. The cursor also
332
+ // advances past the agent's own comments to keep reads cheap.
333
+ async function collectFeedback(sessionId) {
334
+ const session = await store.getSession(sessionId);
335
+ if (!session)
336
+ return [];
337
+ const fresh = await store.listComments({ sessionId, afterSeq: session.agentSeq });
338
+ if (fresh.length === 0)
339
+ return [];
340
+ await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq);
341
+ bus.broadcast({ type: "comment-delivered", sessionId, seq: fresh[fresh.length - 1].seq });
342
+ const feedback = fresh.filter((cm) => cm.author === "user");
343
+ return feedback.length > 0 ? await buildFeedbackBatches(store, feedback) : [];
344
+ }
345
+ // Per-comment delivery state for the viewer: `delivered` once the session's
346
+ // agentSeq has passed the comment. Computed, never stored.
347
+ async function withDelivered(comments) {
348
+ const cursors = new Map();
349
+ for (const c of comments) {
350
+ if (!cursors.has(c.sessionId)) {
351
+ cursors.set(c.sessionId, (await store.getSession(c.sessionId))?.agentSeq ?? 0);
352
+ }
353
+ }
354
+ return comments.map((c) => ({ ...c, delivered: c.seq <= (cursors.get(c.sessionId) ?? 0) }));
355
+ }
356
+ // The workspace's light/dark choice. Older workspaces stored a theme id under
357
+ // `theme`; that registry is gone, so they start from the default mode.
358
+ async function workspaceMode() {
359
+ const stored = await store.getSetting("mode");
360
+ return isMode(stored) ? stored : DEFAULT_MODE;
361
+ }
362
+ // A project's imported design system (settings key `design:<project>`), or
363
+ // null when `mockpit init` has never run for it.
364
+ async function designFor(project) {
365
+ const raw = await store.getSetting(`design:${project}`);
366
+ if (!raw)
367
+ return null;
368
+ try {
369
+ const design = JSON.parse(raw);
370
+ return {
371
+ ...design,
372
+ tailwindCss: typeof design.tailwindCss === "string" ? design.tailwindCss : "",
373
+ strippedImports: Array.isArray(design.strippedImports) ? design.strippedImports : [],
374
+ iconSets: Array.isArray(design.iconSets) ? design.iconSets : [],
375
+ projectKits: Array.isArray(design.projectKits) ? design.projectKits : [],
376
+ designFiles: design.designFiles ?? null,
377
+ };
378
+ }
379
+ catch {
380
+ return null;
381
+ }
382
+ }
383
+ // Parsed installed icon sets by asset id. Asset bytes are immutable (the id
384
+ // is their hash), so an entry never goes stale; the bound only caps memory.
385
+ const iconSetCache = new Map();
386
+ const ICON_SET_CACHE_MAX = 16;
387
+ async function installedIconSet(assetId) {
388
+ if (iconSetCache.has(assetId))
389
+ return iconSetCache.get(assetId);
390
+ const asset = await store.getAsset(assetId);
391
+ // A missing asset is not cached: it may be uploaded a moment later.
392
+ if (!asset)
393
+ return null;
394
+ const set = parseIconSet(new TextDecoder().decode(asset.data));
395
+ if (iconSetCache.size >= ICON_SET_CACHE_MAX) {
396
+ iconSetCache.delete(iconSetCache.keys().next().value);
397
+ }
398
+ iconSetCache.set(assetId, set);
399
+ return set;
400
+ }
401
+ // The project's installed sets first, so installing a set under a bundled
402
+ // prefix (a newer lucide) overrides the bundled copy.
403
+ async function iconSetsFor(design) {
404
+ const out = [];
405
+ for (const ref of design?.iconSets ?? []) {
406
+ const set = await installedIconSet(ref.assetId);
407
+ if (set)
408
+ out.push({ prefix: ref.prefix, set, source: "installed" });
409
+ }
410
+ for (const [prefix, set] of await bundledIconSets()) {
411
+ if (!out.some((s) => s.prefix === prefix))
412
+ out.push({ prefix, set, source: "bundled" });
413
+ }
414
+ return out;
415
+ }
416
+ async function iconResolverFor(design) {
417
+ const sets = await iconSetsFor(design);
418
+ return { resolve: resolverFor(sets.map((s) => s.set)), prefixes: sets.map((s) => s.prefix) };
419
+ }
420
+ // Unknown icon names in a write's html surfaces, said at write time so the
421
+ // agent learns without rendering.
422
+ async function iconWarnings(project, surfaces) {
423
+ const html = surfaces.flatMap((s) => s.kind === "html" && mayHaveIcons(s.html) ? [s.html] : []);
424
+ if (html.length === 0)
425
+ return [];
426
+ const { resolve, prefixes } = await iconResolverFor(await designFor(project));
427
+ const unknown = new Set();
428
+ for (const h of html)
429
+ for (const u of expandIcons(h, resolve).unknown)
430
+ unknown.add(u);
431
+ return [...unknown].map((u) => `unknown icon ${u} (sets: ${prefixes.join(", ")})`);
432
+ }
433
+ // An agent asking for instructions must never get an error page, so a store
434
+ // failure degrades to the brief without project settings.
435
+ async function briefFor(project) {
436
+ try {
437
+ const name = project ?? (await store.listProjects())[0]?.name ?? DEFAULT_PROJECT;
438
+ return renderBriefGuide(await designFor(name));
439
+ }
440
+ catch (err) {
441
+ console.warn("[mockpit] project design unavailable for the brief", err);
442
+ return renderBriefGuide(null);
443
+ }
444
+ }
445
+ // Push + webhooks. Detached on purpose: a dead push endpoint must never fail
446
+ // (or slow) the write that triggered it.
447
+ function fireNotify(payload) {
448
+ void notify(store, payload).catch((err) => console.warn("[mockpit] notify failed", err));
449
+ }
450
+ const mockUrl = (base, mock, post) => {
451
+ const url = `${base}/project/${encodeURIComponent(mock.project)}/${encodeURIComponent(mock.slug)}`;
452
+ if (!post)
453
+ return url;
454
+ const q = new URLSearchParams();
455
+ if (post.state !== null)
456
+ q.set("state", post.state);
457
+ q.set("variant", post.variant);
458
+ return `${url}?${q}`;
459
+ };
460
+ // Project resolution for a session: explicit wins, then the cwd's basename,
461
+ // then the single-workspace fallback.
462
+ const resolveProject = (project, cwd) => project?.trim() || projectFromCwd(cwd) || DEFAULT_PROJECT;
463
+ // A mock reference is its id, or its slug within a project. Without a
464
+ // project, a slug that names exactly one mock in the workspace still resolves —
465
+ // an agent rarely has more than one repo with the same mock name.
466
+ async function resolveMock(ref, project, sessionId) {
467
+ const name = typeof ref === "string" ? ref.trim() : "";
468
+ if (!name)
469
+ return fail(400, 'provide a "mock"');
470
+ const byId = await store.getMock(name);
471
+ if (byId)
472
+ return byId;
473
+ const slug = slugify(name);
474
+ let proj = typeof project === "string" && project.trim() ? project.trim() : undefined;
475
+ if (!proj && typeof sessionId === "string") {
476
+ const session = await store.getSession(sessionId);
477
+ proj = session?.project ?? undefined;
478
+ }
479
+ if (proj) {
480
+ const mock = await store.findMock(proj, slug);
481
+ return mock ?? fail(404, `${proj} has no mock "${slug}"`);
482
+ }
483
+ const matches = (await store.listMocks()).filter((m) => m.slug === slug);
484
+ if (matches.length === 1)
485
+ return matches[0];
486
+ if (matches.length === 0)
487
+ return fail(404, `no mock "${slug}"`);
488
+ return fail(400, `"${slug}" exists in several projects; pass project`, {
489
+ projects: matches.map((m) => m.project),
490
+ });
491
+ }
492
+ const isResult = (v) => !!v && typeof v === "object" && "status" in v && "body" in v;
493
+ // A state argument: undefined = not given, null = the single unnamed state.
494
+ const stateArg = (v) => v === undefined ? undefined : v === null || v === "" ? null : str(v, MAX_LABEL);
495
+ const variantLabel = (p) => (p.state === null ? p.variant : `${p.state}/${p.variant}`);
496
+ // Which variant a call means. Explicit wins; one candidate is unambiguous;
497
+ // several is an error that names them rather than guessing.
498
+ function chooseVariant(mock, posts, stateIn, variant) {
499
+ let candidates = posts;
500
+ if (stateIn !== undefined) {
501
+ if (stateIn !== null && !mock.states.includes(stateIn)) {
502
+ return fail(404, `${mock.slug} has no state "${stateIn}"`, { states: mock.states });
503
+ }
504
+ candidates = candidates.filter((p) => p.state === stateIn);
505
+ }
506
+ if (variant)
507
+ candidates = candidates.filter((p) => p.variant === variant);
508
+ if (candidates.length === 1)
509
+ return candidates[0];
510
+ if (candidates.length === 0) {
511
+ return fail(404, `${mock.slug} has no variant ${variant ? `"${variant}"` : ""}`.trim(), {
512
+ variants: posts.map(variantLabel),
513
+ });
514
+ }
515
+ const live = candidates.filter((p) => p.status !== "archived");
516
+ if (live.length === 1 && !variant)
517
+ return live[0];
518
+ return fail(400, `${mock.slug} has several variants; pass ${variant ? "state" : "variant"}`, {
519
+ variants: candidates.map(variantLabel),
520
+ });
521
+ }
522
+ // The kit ids a surface of this project may name beyond the bundled ones.
523
+ const kitScope = async (project) => ({
524
+ projectKits: ((await designFor(project))?.projectKits ?? []).map((k) => k.id),
525
+ });
526
+ async function parseSurfaceInput(body, ctx, scope, base) {
527
+ if (body.html !== undefined) {
528
+ if (typeof body.html !== "string" || !body.html.trim()) {
529
+ return fail(400, 'body must include non-empty "html" string');
530
+ }
531
+ const raw = [htmlSurface(body.html, body.kits)];
532
+ if (!ctx.strict)
533
+ return coerceSurfaces(raw, scope);
534
+ const parsed = await validateSurfaces(raw, scope);
535
+ return parsed.ok ? parsed.surfaces : fail(400, parsed.error, surfaceIssues(parsed));
536
+ }
537
+ if (!Array.isArray(body.surfaces))
538
+ return fail(400, '"surfaces" must be an array');
539
+ return surfaceList(body.surfaces, ctx, scope, base);
540
+ }
541
+ // The full ordered list replaces the variant's surfaces, so one shape covers
542
+ // add, edit, remove and reorder: an entry that is only `{id}` keeps that
543
+ // surface, an id with content replaces it in place, an entry without an id is
544
+ // new, and an id left out is removed.
545
+ async function surfaceList(raw, ctx, scope, base) {
546
+ const out = [];
547
+ const used = new Set();
548
+ const issues = [];
549
+ for (const [i, entry] of raw.entries()) {
550
+ const record = entry && typeof entry === "object" && !Array.isArray(entry)
551
+ ? entry
552
+ : null;
553
+ const id = typeof record?.id === "string" ? record.id : undefined;
554
+ if (id !== undefined) {
555
+ if (used.has(id))
556
+ return fail(400, `surface "${id}" appears twice in "surfaces"`);
557
+ used.add(id);
558
+ const current = base();
559
+ if (isResult(current))
560
+ return current;
561
+ const kept = current.find((s) => s.id === id);
562
+ if (!kept) {
563
+ return fail(404, `surface "${id}" not found`, { surfaces: current.map(surfaceRef) });
564
+ }
565
+ if (record?.kind === undefined) {
566
+ out.push(kept);
567
+ continue;
568
+ }
569
+ }
570
+ const { id: _id, ...content } = record ?? {};
571
+ const one = record ? [content] : [entry];
572
+ if (!ctx.strict) {
573
+ const [surface] = await coerceSurfaces(one, scope);
574
+ if (surface)
575
+ out.push(id ? { ...surface, id } : surface);
576
+ continue;
577
+ }
578
+ const parsed = await validateSurfaces(one, scope);
579
+ if (!parsed.ok) {
580
+ for (const issue of parsed.issues) {
581
+ issues.push({
582
+ ...issue,
583
+ requestPath: issue.requestPath.replace(/^surfaces\[0\]/, `surfaces[${i}]`),
584
+ });
585
+ }
586
+ continue;
587
+ }
588
+ out.push(id ? { ...parsed.surfaces[0], id } : parsed.surfaces[0]);
589
+ }
590
+ if (issues.length) {
591
+ return fail(400, issues.map((x) => `${x.requestPath}: ${x.message}`).join("; "), {
592
+ code: "surface_validation_failed",
593
+ issues,
594
+ });
595
+ }
596
+ return out;
597
+ }
598
+ const surfaceIssues = (failure) => ({
599
+ code: failure.code,
600
+ issues: failure.issues,
601
+ });
602
+ function checkSurfaces(surfaces) {
603
+ if (surfaces.length === 0)
604
+ return fail(400, "a variant needs at least one surface");
605
+ if (surfacesByteLength(surfaces) > MAX_SURFACE_BYTES) {
606
+ return fail(413, `surface exceeds ${MAX_SURFACE_BYTES} bytes`);
607
+ }
608
+ return null;
609
+ }
610
+ // A page's slot list, with each missing version pinned to the referenced
611
+ // component's current one (snapshot semantics).
612
+ async function pageSlots(project, surfaces) {
613
+ const html = surfaces.find((s) => s.kind === "html");
614
+ if (!html || html.kind !== "html")
615
+ return [];
616
+ const slots = [];
617
+ for (const tag of parseSlotTags(html.html)) {
618
+ let version = tag.version;
619
+ if (version == null)
620
+ version = (await slotTarget(project, tag.slug, tag.variant))?.version ?? null;
621
+ if (version != null)
622
+ slots.push({ slug: tag.slug, variant: tag.variant, version });
623
+ }
624
+ return slots;
625
+ }
626
+ // A slot names a component mock by slug; it renders that mock's first state.
627
+ async function slotTarget(project, slug, variant) {
628
+ const mock = await store.findMock(project, slug);
629
+ if (!mock)
630
+ return null;
631
+ return store.findPost(mock.id, mock.states[0] ?? null, variant);
632
+ }
633
+ // Inline each `<mockpit-slot>` with the referenced variant version's first
634
+ // html surface body. The stored `slots` list (resolved at publish) wins over
635
+ // a bare tag, so a page keeps rendering the versions it was composed from.
636
+ async function expandPageHtml(project, post, html) {
637
+ const pinned = new Map(post.slots.map((s) => [`${s.slug}::${s.variant}`, s.version]));
638
+ const bodies = new Map();
639
+ for (const tag of parseSlotTags(html)) {
640
+ const key = `${tag.slug}::${tag.variant}`;
641
+ const version = tag.version ?? pinned.get(key) ?? null;
642
+ const cacheKey = `${key}::${version}`;
643
+ if (bodies.has(cacheKey))
644
+ continue;
645
+ const target = await slotTarget(project, tag.slug, tag.variant);
646
+ if (!target) {
647
+ bodies.set(cacheKey, null);
648
+ continue;
649
+ }
650
+ const surfaces = version == null || version === target.version
651
+ ? target.surfaces
652
+ : (target.history.find((h) => h.version === version)?.surfaces ?? null);
653
+ const body = surfaces?.find((s) => s.kind === "html");
654
+ bodies.set(cacheKey, body && body.kind === "html" ? body.html : null);
655
+ }
656
+ return expandSlots(html, ({ slug, variant, version }) => {
657
+ const key = `${slug}::${variant}`;
658
+ const pinnedVersion = version ?? pinned.get(key) ?? null;
659
+ return bodies.get(`${key}::${pinnedVersion}`) ?? null;
660
+ });
661
+ }
662
+ // Knobs with a handful of discrete choices are usually decisions dressed as
663
+ // controls: the user can't compare options they only see one at a time.
664
+ function knobNudges(knobs) {
665
+ const out = [];
666
+ for (const [path, config] of Object.entries(knobs)) {
667
+ const n = discreteChoices(config);
668
+ if (n !== null && n <= 3) {
669
+ out.push(`knob "${path}" has ${n} discrete options: if each option needs its own render, ask instead (ask with options bound to variants); keep a knob when one render plus a control shows it`);
670
+ }
671
+ }
672
+ return out;
673
+ }
674
+ // Ask discipline: two live variants of a state with no ask binding them all
675
+ // leave the user nothing to answer in one Send, and the agent tends to ask in
676
+ // chat instead. The nudge carries a ready ask: mock-wide when every state has
677
+ // the same variant names, else for this state.
678
+ function unboundVariants(mock, posts, state) {
679
+ const live = (s) => posts.filter((p) => p.state === s && p.status !== "archived").map((p) => p.variant);
680
+ const names = live(state);
681
+ if (names.length < 2)
682
+ return null;
683
+ const bound = mock.asks.some((a) => {
684
+ if (a.scope === "part" || (a.scope === "state" && a.state !== state))
685
+ return false;
686
+ const variants = new Set(a.options.map((o) => o.variant));
687
+ return names.every((n) => variants.has(n));
688
+ });
689
+ if (bound)
690
+ return null;
691
+ const key = (list) => [...list].sort().join("\n");
692
+ const states = mock.states.length ? mock.states : [null];
693
+ const wide = states.every((s) => key(live(s)) === key(names));
694
+ const options = names.map((v) => ({ label: v, variant: v }));
695
+ const ask = wide
696
+ ? { id: BUILTIN_ASK_ID, text: "Which one?", scope: "mock", options }
697
+ : {
698
+ id: `${BUILTIN_ASK_ID}-${slugify(state ?? "")}`,
699
+ text: "Which one?",
700
+ scope: "state",
701
+ state,
702
+ options,
703
+ };
704
+ const where = state === null ? mock.slug : `${mock.slug} state "${state}"`;
705
+ return {
706
+ nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk (or your own question with options bound to the variants) so the user can choose in one Send; never ask in chat`,
707
+ ask,
708
+ };
709
+ }
710
+ // The answer to every write: what was published, the parts found per state,
711
+ // what moved since the previous version, and any feedback waiting.
712
+ async function writeResult(mock, post, ctx, extra = {}) {
713
+ const posts = await store.listPosts({ mockId: mock.id });
714
+ const detail = mockDetailView(mock, posts);
715
+ const unbound = unboundVariants(mock, posts, post.state);
716
+ const nudges = [...(extra.nudges ?? []), ...(unbound ? [unbound.nudge] : [])];
717
+ let partChanges;
718
+ if (extra.previous) {
719
+ partChanges = diffParts(partsInSurfaces(extra.previous), partsInSurfaces(post.surfaces));
720
+ }
721
+ const warnings = await iconWarnings(mock.project, post.surfaces);
722
+ const feedback = await collectFeedback(post.sessionId);
723
+ return ok({
724
+ mock: {
725
+ id: mock.id,
726
+ project: mock.project,
727
+ slug: mock.slug,
728
+ title: mock.title,
729
+ kind: mock.kind,
730
+ states: mock.states,
731
+ },
732
+ post: {
733
+ id: post.id,
734
+ state: post.state,
735
+ variant: post.variant,
736
+ version: post.version,
737
+ status: post.status,
738
+ surfaces: post.surfaces.map(surfaceRef),
739
+ },
740
+ sessionId: post.sessionId,
741
+ url: mockUrl(ctx.base, mock, post),
742
+ parts: detail.parts,
743
+ ...(extra.applied ? { applied: extra.applied } : {}),
744
+ ...(partChanges && (partChanges.vanished.length || partChanges.renamed.length)
745
+ ? { partChanges }
746
+ : {}),
747
+ ...(nudges.length ? { nudges } : {}),
748
+ ...(unbound ? { suggestedAsk: unbound.ask } : {}),
749
+ ...(warnings.length ? { warnings } : {}),
750
+ feedback,
751
+ }, extra.status ?? 200);
752
+ }
753
+ function announcePost(mock, post, created, by = "agent") {
754
+ bus.broadcast({
755
+ type: created ? "post-created" : "post-updated",
756
+ id: post.id,
757
+ mockId: mock.id,
758
+ sessionId: post.sessionId,
759
+ version: post.version,
760
+ by,
761
+ });
762
+ warmPost(post, mock);
763
+ }
764
+ // The one write: publish one variant of one state of a mock. An existing
765
+ // (mock, state, variant) becomes a new version; anything else is created.
766
+ // Exactly one body: `html`, the full ordered `surfaces` list, or `parts`
767
+ // spliced into the variant's latest (or `from`) version.
768
+ async function publishFlow(body, ctx) {
769
+ if (!body || typeof body !== "object")
770
+ return fail(400, "invalid JSON body");
771
+ const slugIn = str(body.mock, MAX_TITLE);
772
+ if (!slugIn)
773
+ return fail(400, 'provide "mock": the mock slug, e.g. "writer"');
774
+ const given = ["html", "surfaces", "parts"].filter((k) => body[k] !== undefined);
775
+ if (given.length === 0)
776
+ return fail(400, 'provide "html", "surfaces" or "parts"');
777
+ if (given.length > 1) {
778
+ return fail(400, `pass one of "html", "surfaces" or "parts", not ${given.join(" and ")}`);
779
+ }
780
+ const partEdits = partEditsArg(body);
781
+ if (isResult(partEdits))
782
+ return partEdits;
783
+ const knobs = checkKnobs(body.knobs);
784
+ if (!knobs.ok)
785
+ return fail(400, knobs.error);
786
+ const variantKnobs = checkKnobs(body.variantKnobs);
787
+ if (!variantKnobs.ok)
788
+ return fail(400, variantKnobs.error);
789
+ const stateIn = stateArg(body.state);
790
+ const variantIn = str(body.variant, MAX_LABEL);
791
+ const kind = body.kind === "page" || body.kind === "component" ? body.kind : undefined;
792
+ const title = str(body.title, MAX_TITLE);
793
+ const fromN = Number(body.from);
794
+ const from = Number.isInteger(fromN) && fromN > 0 ? fromN : undefined;
795
+ const prompt = str(body.prompt, MAX_COMMENT_TEXT);
796
+ const author = str(body.author, MAX_TITLE);
797
+ // An existing mock is addressable by id before a session exists.
798
+ let mock = null;
799
+ if (typeof body.mock === "string")
800
+ mock = await store.getMock(body.mock);
801
+ let session = null;
802
+ if (typeof body.session === "string" && body.session) {
803
+ session = await store.getSession(body.session);
804
+ if (!session)
805
+ return fail(404, `session "${body.session}" not found`);
806
+ }
807
+ const project = mock?.project ??
808
+ (str(body.project, MAX_TITLE) ||
809
+ session?.project ||
810
+ projectFromCwd(session?.cwd) ||
811
+ resolveProject(undefined, str(body.cwd, 4096)));
812
+ const slug = mock?.slug ?? slugify(slugIn);
813
+ mock ??= await store.findMock(project, slug);
814
+ const posts = mock ? await store.listPosts({ mockId: mock.id }) : [];
815
+ let state;
816
+ if (stateIn === undefined || stateIn === null) {
817
+ if (mock && mock.states.length > 0) {
818
+ return fail(400, `${slug} has states; pass state`, { states: mock.states });
819
+ }
820
+ state = null;
821
+ }
822
+ else {
823
+ if (mock && mock.states.length === 0 && posts.length > 0) {
824
+ return fail(409, `${slug} is a single-state mock; its variants have no state name. Publish a multi-state design as a new mock and name every state`);
825
+ }
826
+ state = stateIn;
827
+ }
828
+ const inState = posts.filter((p) => p.state === state);
829
+ let variant = variantIn;
830
+ if (!variant) {
831
+ const live = inState.filter((p) => p.status !== "archived");
832
+ if (inState.length === 0)
833
+ variant = DEFAULT_VARIANT;
834
+ else if (inState.length === 1)
835
+ variant = inState[0].variant;
836
+ else if (live.length === 1)
837
+ variant = live[0].variant;
838
+ else {
839
+ return fail(400, `${slug} has several variants; pass variant`, {
840
+ variants: inState.map(variantLabel),
841
+ });
842
+ }
843
+ }
844
+ const target = inState.find((p) => p.variant === variant) ?? null;
845
+ const missing = () => fail(404, `${slug} has no variant "${variant}"${state === null ? "" : ` in "${state}"`}`, {
846
+ variants: posts.map(variantLabel),
847
+ });
848
+ // What `parts` and kept surface ids refer to: the variant's current
849
+ // version, or the one `from` names.
850
+ const base = () => {
851
+ if (!target)
852
+ return missing();
853
+ if (from === undefined || from === target.version)
854
+ return target.surfaces;
855
+ return (target.history.find((h) => h.version === from)?.surfaces ??
856
+ fail(404, `${slug} has no version ${from}`));
857
+ };
858
+ let surfaces;
859
+ let applied;
860
+ if (partEdits) {
861
+ const current = base();
862
+ if (isResult(current))
863
+ return current;
864
+ const result = spliceParts(current, partEdits);
865
+ if (!result.ok)
866
+ return fail(400, result.error);
867
+ surfaces = result.surfaces;
868
+ applied = result.applied;
869
+ }
870
+ else {
871
+ const parsed = await parseSurfaceInput(body, ctx, await kitScope(project), base);
872
+ if (isResult(parsed))
873
+ return parsed;
874
+ surfaces = parsed;
875
+ }
876
+ const bad = checkSurfaces(surfaces);
877
+ if (bad)
878
+ return bad;
879
+ const nudges = knobNudges({ ...knobs.value, ...variantKnobs.value });
880
+ if (!target) {
881
+ // A new variant: carried by the session the agent is writing from.
882
+ if (!session) {
883
+ session = await store.createSession({
884
+ agent: str(body.agent, MAX_TITLE) ?? "agent",
885
+ title: str(body.sessionTitle, MAX_TITLE),
886
+ cwd: str(body.cwd, 4096),
887
+ project,
888
+ });
889
+ bus.broadcast({ type: "session-created", id: session.id });
890
+ }
891
+ if (!mock) {
892
+ mock = await store.createMock({
893
+ project,
894
+ slug,
895
+ title: title ?? titleFromSlug(slug),
896
+ kind: kind ?? "component",
897
+ states: state === null ? [] : [state],
898
+ knobs: knobs.value,
899
+ sessionId: session.id,
900
+ });
901
+ bus.broadcast({ type: "mock-created", id: mock.id, project });
902
+ }
903
+ const slots = mock.kind === "page" || kind === "page" ? await pageSlots(project, surfaces) : [];
904
+ const created = await store.createPost({
905
+ sessionId: session.id,
906
+ mock: mock.id,
907
+ state,
908
+ variant,
909
+ title: title ?? mock.title,
910
+ surfaces,
911
+ ...(Object.keys(variantKnobs.value).length ? { knobs: variantKnobs.value } : {}),
912
+ slots,
913
+ from,
914
+ prompt,
915
+ author,
916
+ });
917
+ if (!created)
918
+ return fail(404, "session not found");
919
+ mock = await updateMockAfterWrite(mock, { state, kind, title, knobs: knobs.value, session });
920
+ announcePost(mock, created, true);
921
+ if (prompt && ctx.request)
922
+ notifyPublish(mock, created, prompt, ctx);
923
+ return writeResult(mock, created, ctx, { nudges, status: 201 });
924
+ }
925
+ if (!mock)
926
+ return missing();
927
+ // A new version is written by whoever writes it; without an explicit
928
+ // session the variant's own session carries it.
929
+ session ??= await store.getSession(target.sessionId);
930
+ const slots = mock.kind === "page" ? await pageSlots(project, surfaces) : undefined;
931
+ const updated = await store.updatePost(target.id, {
932
+ surfaces,
933
+ title,
934
+ ...(body.variantKnobs !== undefined ? { knobs: variantKnobs.value } : {}),
935
+ slots,
936
+ from,
937
+ prompt,
938
+ author,
939
+ });
940
+ if (!updated)
941
+ return fail(404, "variant not found");
942
+ mock = await updateMockAfterWrite(mock, { state, kind, knobs: knobs.value, session });
943
+ announcePost(mock, updated, false);
944
+ if (prompt && ctx.request)
945
+ notifyPublish(mock, updated, prompt, ctx);
946
+ return writeResult(mock, updated, ctx, { previous: target.surfaces, applied, nudges });
947
+ }
948
+ // `parts`: new outer html per part ("name" or "name#key"), spliced into the
949
+ // variant's html so a tweak costs the tweak rather than the whole document.
950
+ function partEditsArg(body) {
951
+ if (body?.parts === undefined)
952
+ return undefined;
953
+ const parts = body.parts;
954
+ if (!parts || typeof parts !== "object" || Array.isArray(parts)) {
955
+ return fail(400, '"parts" must be an object: { "<part name>": "<html for that part>" }');
956
+ }
957
+ const entries = Object.entries(parts);
958
+ if (entries.length === 0)
959
+ return fail(400, '"parts" is empty; name at least one part');
960
+ if (entries.some(([, html]) => typeof html !== "string")) {
961
+ return fail(400, 'each "parts" value must be the html string for that part');
962
+ }
963
+ return parts;
964
+ }
965
+ // A write may add a state, retitle the mock, declare knobs, and makes its
966
+ // session the one a reply is delivered to.
967
+ async function updateMockAfterWrite(mock, w) {
968
+ const states = w.state !== null && !mock.states.includes(w.state) ? [...mock.states, w.state] : undefined;
969
+ const knobs = Object.keys(w.knobs).length ? { ...mock.knobs, ...w.knobs } : undefined;
970
+ const updated = await store.updateMock(mock.id, {
971
+ ...(states ? { states } : {}),
972
+ ...(w.kind ? { kind: w.kind } : {}),
973
+ ...(w.title ? { title: w.title } : {}),
974
+ ...(knobs ? { knobs } : {}),
975
+ ...(w.session ? { sessionId: w.session.id } : {}),
976
+ });
977
+ if (!updated)
978
+ return mock;
979
+ bus.broadcast({ type: "mock-updated", id: updated.id, project: updated.project });
980
+ return updated;
981
+ }
982
+ function notifyPublish(mock, post, text, ctx) {
983
+ fireNotify({
984
+ event: "publish",
985
+ project: mock.project,
986
+ slug: mock.slug,
987
+ variant: post.variant,
988
+ version: post.version,
989
+ text,
990
+ url: mockUrl(ctx.base, mock, post),
991
+ });
992
+ }
993
+ async function listMocksFlow(query) {
994
+ const project = str(query.project, MAX_TITLE);
995
+ const mocks = await store.listMocks(project);
996
+ const rows = [];
997
+ const pending = [];
998
+ for (const m of mocks) {
999
+ const posts = await store.listPosts({ mockId: m.id });
1000
+ rows.push(mockSummaryView(m, posts));
1001
+ pending.push(pendingOf(m, posts));
1002
+ }
1003
+ const open = rows.reduce((n, r) => n + r.open, 0);
1004
+ return ok({
1005
+ ...(project ? { project } : {}),
1006
+ mocks: rows,
1007
+ open,
1008
+ openMocks: rows.filter((r) => r.open > 0).length,
1009
+ pending,
1010
+ });
1011
+ }
1012
+ // The tuned values of the mock's latest reply: what the user last sent.
1013
+ async function lastTuned(mockId) {
1014
+ const replies = (await store.listComments({ mockId })).filter((c) => c.kind === "reply");
1015
+ return replies[replies.length - 1]?.payload?.tuned ?? {};
1016
+ }
1017
+ async function getMockFlow(ref, query) {
1018
+ const mock = await resolveMock(ref, query.project, query.session);
1019
+ if (isResult(mock))
1020
+ return mock;
1021
+ const posts = await store.listPosts({ mockId: mock.id });
1022
+ return ok({
1023
+ ...mockDetailView(mock, posts, {
1024
+ body: query.body,
1025
+ history: query.history,
1026
+ tuned: await lastTuned(mock.id),
1027
+ }),
1028
+ pending: pendingOf(mock, posts),
1029
+ });
1030
+ }
1031
+ async function exportFlow(ref, query, ctx) {
1032
+ const mock = await resolveMock(ref, query.project);
1033
+ if (isResult(mock))
1034
+ return mock;
1035
+ const posts = await store.listPosts({ mockId: mock.id });
1036
+ const wantedState = stateArg(query.state);
1037
+ const wantedVariant = str(query.variant, MAX_LABEL);
1038
+ const states = (mock.states.length ? mock.states : [null]).filter((s) => wantedState === undefined || s === wantedState);
1039
+ if (states.length === 0)
1040
+ return fail(404, `${mock.slug} has no state "${wantedState}"`);
1041
+ const entries = [];
1042
+ for (const state of states) {
1043
+ const inState = posts.filter((p) => p.state === state);
1044
+ const post = (wantedVariant && inState.find((p) => p.variant === wantedVariant)) ||
1045
+ inState.find((p) => p.status === "accepted") ||
1046
+ inState.find((p) => p.status === "open") ||
1047
+ inState[0];
1048
+ if (!post)
1049
+ continue;
1050
+ if (wantedVariant && post.variant !== wantedVariant)
1051
+ continue;
1052
+ const html = post.surfaces.find((s) => s.kind === "html");
1053
+ entries.push({
1054
+ state,
1055
+ variant: post.variant,
1056
+ postId: post.id,
1057
+ version: post.version,
1058
+ status: post.status,
1059
+ html: html && html.kind === "html" ? html.html : "",
1060
+ markdown: postToMarkdown(post, {
1061
+ postUrl: `${ctx.base}/s/${post.id}`,
1062
+ assetBase: ctx.base,
1063
+ }),
1064
+ history: [
1065
+ ...[...post.history]
1066
+ .sort((a, b) => a.version - b.version)
1067
+ .map((h) => ({
1068
+ version: h.version,
1069
+ at: h.at,
1070
+ from: h.from ?? null,
1071
+ prompt: h.prompt ?? "",
1072
+ })),
1073
+ {
1074
+ version: post.version,
1075
+ at: post.updatedAt,
1076
+ from: post.from ?? null,
1077
+ prompt: post.prompt ?? "",
1078
+ },
1079
+ ],
1080
+ screenshotUrl: screenshots ? `${ctx.base}/s/${post.id}.png?v=${post.version}` : null,
1081
+ });
1082
+ }
1083
+ if (entries.length === 0) {
1084
+ return fail(404, `${mock.slug} has no variant "${wantedVariant}"`, {
1085
+ variants: posts.map(variantLabel),
1086
+ });
1087
+ }
1088
+ const replies = (await store.listComments({ mockId: mock.id })).filter((c) => c.kind === "reply");
1089
+ return ok({
1090
+ project: mock.project,
1091
+ mock: mock.slug,
1092
+ title: mock.title,
1093
+ kind: mock.kind,
1094
+ states: entries,
1095
+ knobs: mock.knobs,
1096
+ reply: replies[replies.length - 1]?.payload ?? null,
1097
+ });
1098
+ }
1099
+ // --- asks ---
1100
+ function sanitizeAsk(raw, mock, posts, taken) {
1101
+ if (!raw || typeof raw !== "object")
1102
+ return "each ask must be an object";
1103
+ const text = str(raw.text, MAX_COMMENT_TEXT);
1104
+ if (!text)
1105
+ return 'each ask needs "text"';
1106
+ const scope = raw.scope === "state" || raw.scope === "part" ? raw.scope : "mock";
1107
+ const state = str(raw.state, MAX_LABEL);
1108
+ if (scope === "state") {
1109
+ if (!state)
1110
+ return `ask "${text}" has scope "state" but no state`;
1111
+ if (!mock.states.includes(state))
1112
+ return `${mock.slug} has no state "${state}"`;
1113
+ }
1114
+ else if (state && !mock.states.includes(state)) {
1115
+ return `${mock.slug} has no state "${state}"`;
1116
+ }
1117
+ const part = str(raw.part, 200);
1118
+ if (scope === "part" && !part)
1119
+ return `ask "${text}" has scope "part" but no part`;
1120
+ if (!Array.isArray(raw.options) || raw.options.length === 0) {
1121
+ return `ask "${text}" needs options`;
1122
+ }
1123
+ if (raw.options.length > MAX_OPTIONS)
1124
+ return `ask "${text}" has more than ${MAX_OPTIONS} options`;
1125
+ const variants = new Set(posts.map((p) => p.variant));
1126
+ const options = [];
1127
+ // Seeded with the viewer's write-in, so a label "Other" slugs to "other-2".
1128
+ const optionIds = new Set([OTHER_ID]);
1129
+ for (const o of raw.options) {
1130
+ const opt = typeof o === "string" ? { label: o } : o;
1131
+ const label = str(opt?.label, 200);
1132
+ if (!label)
1133
+ return `every option of "${text}" needs a label`;
1134
+ if (str(opt.id, 64) && slugify(str(opt.id, 64)) === OTHER_ID) {
1135
+ return `option id "${OTHER_ID}" is reserved: the viewer adds its own "Other…" write-in to every ask`;
1136
+ }
1137
+ let id = slugify(str(opt.id, 64) ?? label);
1138
+ for (let n = 2; optionIds.has(id); n++)
1139
+ id = `${slugify(str(opt.id, 64) ?? label)}-${n}`;
1140
+ optionIds.add(id);
1141
+ const variant = str(opt.variant, MAX_LABEL);
1142
+ if (variant && !variants.has(variant)) {
1143
+ return `option "${label}" names variant "${variant}", which ${mock.slug} does not have`;
1144
+ }
1145
+ let set;
1146
+ if (opt.set !== undefined) {
1147
+ const checked = checkKnobValues(opt.set, mock, posts, `option "${label}"`);
1148
+ if (!checked.ok)
1149
+ return checked.error;
1150
+ set = checked.value;
1151
+ }
1152
+ options.push({ id, label, ...(variant ? { variant } : {}), ...(set ? { set } : {}) });
1153
+ }
1154
+ let id = slugify(str(raw.id, 64) ?? text).slice(0, 40);
1155
+ if (!str(raw.id, 64))
1156
+ for (let n = 2; taken.has(id); n++)
1157
+ id = `${slugify(text).slice(0, 36)}-${n}`;
1158
+ return {
1159
+ id,
1160
+ text,
1161
+ scope,
1162
+ ...(state ? { state } : {}),
1163
+ ...(part ? { part } : {}),
1164
+ options,
1165
+ ...(raw.multi === true ? { multi: true } : {}),
1166
+ at: new Date().toISOString(),
1167
+ };
1168
+ }
1169
+ // Ask the user structured questions. An ask whose id matches an existing one
1170
+ // replaces it (and reopens it); anything else is appended.
1171
+ async function askFlow(ref, body, ctx) {
1172
+ if (!body || typeof body !== "object")
1173
+ return fail(400, "invalid JSON body");
1174
+ const resolved = await resolveMock(ref, body.project, body.session);
1175
+ if (isResult(resolved))
1176
+ return resolved;
1177
+ let mock = resolved;
1178
+ const raw = Array.isArray(body.asks) ? body.asks : body.text ? [body] : null;
1179
+ if (!raw || raw.length === 0)
1180
+ return fail(400, 'provide "asks": [{text, options}]');
1181
+ if (raw.length > MAX_ASKS)
1182
+ return fail(400, `at most ${MAX_ASKS} asks per call`);
1183
+ const posts = await store.listPosts({ mockId: mock.id });
1184
+ const asks = [...mock.asks];
1185
+ const taken = new Set(asks.map((a) => a.id));
1186
+ const added = [];
1187
+ for (const entry of raw) {
1188
+ const ask = sanitizeAsk(entry, mock, posts, taken);
1189
+ if (typeof ask === "string")
1190
+ return fail(400, ask);
1191
+ const at = asks.findIndex((a) => a.id === ask.id);
1192
+ if (at >= 0)
1193
+ asks[at] = ask;
1194
+ else
1195
+ asks.push(ask);
1196
+ taken.add(ask.id);
1197
+ added.push(ask);
1198
+ }
1199
+ let sessionId = mock.sessionId;
1200
+ if (typeof body.session === "string" && (await store.getSession(body.session))) {
1201
+ sessionId = body.session;
1202
+ }
1203
+ const updated = await store.updateMock(mock.id, { asks, sessionId });
1204
+ if (!updated)
1205
+ return fail(404, "mock not found");
1206
+ mock = updated;
1207
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
1208
+ // The questions also land in the thread, so the user reads them where they answer.
1209
+ const session = sessionId ? await store.getSession(sessionId) : null;
1210
+ if (session) {
1211
+ const comment = await store.createComment({
1212
+ sessionId: session.id,
1213
+ mockId: mock.id,
1214
+ author: reservedAgent(session.agent),
1215
+ text: added.map((a) => a.text).join("\n"),
1216
+ kind: "ask",
1217
+ });
1218
+ if (comment)
1219
+ announceComment(comment);
1220
+ }
1221
+ fireNotify({
1222
+ event: "ask",
1223
+ project: mock.project,
1224
+ slug: mock.slug,
1225
+ variant: "",
1226
+ version: Math.max(1, ...posts.map((p) => p.version)),
1227
+ text: added.map((a) => a.text).join(" · "),
1228
+ url: mockUrl(ctx.base, mock),
1229
+ });
1230
+ const feedback = session ? await collectFeedback(session.id) : [];
1231
+ return ok({
1232
+ mock: mock.slug,
1233
+ mockId: mock.id,
1234
+ project: mock.project,
1235
+ asks: added,
1236
+ open: openCount(mock, posts),
1237
+ url: mockUrl(ctx.base, mock),
1238
+ feedback,
1239
+ });
1240
+ }
1241
+ // --- drafts and replies (the user's side; viewer origin only) ---
1242
+ function sanitizePartComments(raw, mock) {
1243
+ if (raw === undefined || raw === null)
1244
+ return [];
1245
+ if (!Array.isArray(raw))
1246
+ return '"comments" must be an array';
1247
+ if (raw.length > MAX_DRAFT_COMMENTS)
1248
+ return `at most ${MAX_DRAFT_COMMENTS} comments`;
1249
+ const out = [];
1250
+ for (const c of raw) {
1251
+ const text = str(c?.text, MAX_COMMENT_TEXT);
1252
+ if (!text)
1253
+ return "every comment needs text";
1254
+ const state = stateArg(c.state) ?? null;
1255
+ if (state !== null && !mock.states.includes(state))
1256
+ return `${mock.slug} has no state "${state}"`;
1257
+ const part = str(c.part, 200) ?? null;
1258
+ const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
1259
+ const anchor = a ? sanitizePartAnchor(a) : undefined;
1260
+ out.push({ part, state, text, ...(anchor ? { anchor } : {}) });
1261
+ }
1262
+ return out;
1263
+ }
1264
+ function sanitizePartAnchor(a) {
1265
+ const offset = Array.isArray(a.offset) && a.offset.length === 2 && a.offset.every((n) => Number.isFinite(n))
1266
+ ? [Number(a.offset[0]), Number(a.offset[1])]
1267
+ : undefined;
1268
+ const box = Array.isArray(a.box) && a.box.length === 4 && a.box.every((n) => Number.isFinite(n))
1269
+ ? a.box.map(Number)
1270
+ : undefined;
1271
+ const quote = str(a.quote, 200);
1272
+ const selector = str(a.selector, 500);
1273
+ if (!offset && !box && !quote && !selector)
1274
+ return undefined;
1275
+ return {
1276
+ ...(offset ? { offset } : {}),
1277
+ ...(quote ? { quote } : {}),
1278
+ ...(selector ? { selector } : {}),
1279
+ ...(box ? { box } : {}),
1280
+ };
1281
+ }
1282
+ // A stored ask, else the viewer's built-in variant ask under its reserved id.
1283
+ const askById = (mock, posts, id) => mock.asks.find((a) => a.id === id) ?? builtinAsk(mock, posts, id);
1284
+ function sanitizeAnswers(raw, mock, posts) {
1285
+ if (raw === undefined || raw === null)
1286
+ return {};
1287
+ if (typeof raw !== "object" || Array.isArray(raw))
1288
+ return '"answers" must be an object';
1289
+ const out = {};
1290
+ for (const [askId, value] of Object.entries(raw)) {
1291
+ const ask = askById(mock, posts, askId);
1292
+ if (!ask)
1293
+ return `${mock.slug} has no ask "${askId}"`;
1294
+ const ids = Array.isArray(value) ? value : [value];
1295
+ if (ids.length === 0)
1296
+ continue;
1297
+ if (!ask.multi && ids.length > 1)
1298
+ return `ask "${askId}" takes one answer`;
1299
+ for (const id of ids) {
1300
+ if (typeof id !== "string" || !ask.options.some((o) => o.id === id)) {
1301
+ return `ask "${askId}" has no option "${String(id)}"`;
1302
+ }
1303
+ }
1304
+ out[askId] = ask.multi ? ids : ids[0];
1305
+ }
1306
+ return out;
1307
+ }
1308
+ // askId → text, for the write-ins and the notes: known asks only, text capped
1309
+ // like a comment, blanks dropped.
1310
+ function sanitizeAskTexts(raw, mock, posts, key) {
1311
+ if (raw === undefined || raw === null)
1312
+ return {};
1313
+ if (typeof raw !== "object" || Array.isArray(raw))
1314
+ return `"${key}" must be an object`;
1315
+ const out = {};
1316
+ for (const [askId, value] of Object.entries(raw)) {
1317
+ if (!askById(mock, posts, askId))
1318
+ return `${mock.slug} has no ask "${askId}"`;
1319
+ if (value !== undefined && value !== null && typeof value !== "string") {
1320
+ return `${key} "${askId}" must be text`;
1321
+ }
1322
+ const text = str(value, MAX_COMMENT_TEXT);
1323
+ if (text)
1324
+ out[askId] = text;
1325
+ }
1326
+ return out;
1327
+ }
1328
+ function sanitizeMix(raw, posts) {
1329
+ if (raw === undefined || raw === null)
1330
+ return {};
1331
+ if (typeof raw !== "object" || Array.isArray(raw))
1332
+ return '"mix" must be an object';
1333
+ const variants = new Set(posts.map((p) => p.variant));
1334
+ const out = {};
1335
+ for (const [part, variant] of Object.entries(raw)) {
1336
+ if (!part || part.length > 200)
1337
+ return "mix keys are part names";
1338
+ if (typeof variant !== "string" || !variants.has(variant)) {
1339
+ return `mix "${part}" names a variant that does not exist`;
1340
+ }
1341
+ out[part] = variant;
1342
+ }
1343
+ return out;
1344
+ }
1345
+ // A draft, validated against the mock as it is now: unknown asks, options,
1346
+ // variants, knob paths or out-of-range values are refused, never stored.
1347
+ function sanitizeDraft(raw, mock, posts, base) {
1348
+ const pick = (key) => (raw?.[key] !== undefined ? raw[key] : base?.[key]);
1349
+ const answers = sanitizeAnswers(pick("answers"), mock, posts);
1350
+ if (typeof answers === "string")
1351
+ return answers;
1352
+ const mix = sanitizeMix(pick("mix"), posts);
1353
+ if (typeof mix === "string")
1354
+ return mix;
1355
+ const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
1356
+ if (!tuned.ok)
1357
+ return tuned.error;
1358
+ const comments = sanitizePartComments(pick("comments"), mock);
1359
+ if (typeof comments === "string")
1360
+ return comments;
1361
+ const others = sanitizeAskTexts(pick("others"), mock, posts, "others");
1362
+ if (typeof others === "string")
1363
+ return others;
1364
+ const notes = sanitizeAskTexts(pick("notes"), mock, posts, "notes");
1365
+ if (typeof notes === "string")
1366
+ return notes;
1367
+ for (const askId of Object.keys(others)) {
1368
+ const ask = askById(mock, posts, askId);
1369
+ if (!ask?.multi && answers[askId] !== undefined)
1370
+ return `ask "${askId}" takes one answer`;
1371
+ }
1372
+ const versionN = Number(pick("version"));
1373
+ const version = Number.isInteger(versionN) && versionN > 0
1374
+ ? versionN
1375
+ : Math.max(1, ...posts.map((p) => p.version));
1376
+ return {
1377
+ version,
1378
+ answers,
1379
+ mix,
1380
+ tuned: tuned.value,
1381
+ comments,
1382
+ others,
1383
+ notes,
1384
+ updatedAt: new Date().toISOString(),
1385
+ };
1386
+ }
1387
+ const viewerOnly = () => fail(403, "only the viewer can do this");
1388
+ async function getDraftFlow(ref, ctx) {
1389
+ if (!ctx.viewer)
1390
+ return viewerOnly();
1391
+ const mock = await resolveMock(ref);
1392
+ if (isResult(mock))
1393
+ return mock;
1394
+ return ok({ draft: mock.draft });
1395
+ }
1396
+ async function putDraftFlow(ref, body, ctx) {
1397
+ if (!ctx.viewer)
1398
+ return viewerOnly();
1399
+ if (!body || typeof body !== "object")
1400
+ return fail(400, "invalid JSON body");
1401
+ const mock = await resolveMock(ref);
1402
+ if (isResult(mock))
1403
+ return mock;
1404
+ const posts = await store.listPosts({ mockId: mock.id });
1405
+ const draft = sanitizeDraft(body, mock, posts, null);
1406
+ if (typeof draft === "string")
1407
+ return fail(400, draft);
1408
+ await store.putDraft(mock.id, draft);
1409
+ bus.broadcast({ type: "draft-updated", mockId: mock.id });
1410
+ return ok({ draft });
1411
+ }
1412
+ async function deleteDraftFlow(ref, ctx) {
1413
+ if (!ctx.viewer)
1414
+ return viewerOnly();
1415
+ const mock = await resolveMock(ref);
1416
+ if (isResult(mock))
1417
+ return mock;
1418
+ await store.putDraft(mock.id, null);
1419
+ bus.broadcast({ type: "draft-updated", mockId: mock.id });
1420
+ return ok({ draft: null });
1421
+ }
1422
+ function sanitizeDecision(raw, mock, posts) {
1423
+ if (raw === undefined || raw === null)
1424
+ return undefined;
1425
+ const kind = raw.kind;
1426
+ if (kind !== "accept" && kind !== "revise" && kind !== "drop") {
1427
+ return 'decision.kind must be "accept", "revise" or "drop"';
1428
+ }
1429
+ const chosen = chooseVariant(mock, posts, stateArg(raw.state), str(raw.variant, MAX_LABEL));
1430
+ if (isResult(chosen))
1431
+ return chosen.body.error;
1432
+ return { kind, state: chosen.state, variant: chosen.variant };
1433
+ }
1434
+ // Which variants a reply accepts and archives. A variant-bound answer accepts
1435
+ // the chosen variant(s) in every state the ask covers and archives their
1436
+ // siblings there; a part-scoped pick is a mix, not a verdict. The built-in
1437
+ // variant asks flip the same way, per state.
1438
+ function replyFlips(mock, posts, answers, decision) {
1439
+ const accept = new Set();
1440
+ const archive = new Set();
1441
+ const settle = (inState, chosen) => {
1442
+ if (!inState.some((p) => chosen.has(p.variant)))
1443
+ return;
1444
+ for (const p of inState) {
1445
+ if (chosen.has(p.variant))
1446
+ accept.add(p.id);
1447
+ else if (p.status !== "archived")
1448
+ archive.add(p.id);
1449
+ }
1450
+ };
1451
+ const builtins = Object.keys(answers)
1452
+ .filter((id) => !mock.asks.some((a) => a.id === id))
1453
+ .flatMap((id) => builtinAsk(mock, posts, id) ?? []);
1454
+ for (const ask of [...mock.asks, ...builtins]) {
1455
+ const answer = answers[ask.id];
1456
+ if (answer === undefined || ask.scope === "part")
1457
+ continue;
1458
+ const ids = Array.isArray(answer) ? answer : [answer];
1459
+ const chosen = new Set(ask.options.filter((o) => ids.includes(o.id) && o.variant).map((o) => o.variant));
1460
+ if (chosen.size === 0)
1461
+ continue;
1462
+ const states = ask.scope === "state" ? [ask.state ?? null] : mock.states.length ? mock.states : [null];
1463
+ for (const state of states)
1464
+ settle(posts.filter((p) => p.state === state), chosen);
1465
+ }
1466
+ if (decision?.kind === "accept") {
1467
+ settle(posts.filter((p) => p.state === decision.state), new Set([decision.variant]));
1468
+ }
1469
+ else if (decision?.kind === "drop") {
1470
+ const post = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
1471
+ if (post)
1472
+ archive.add(post.id);
1473
+ }
1474
+ for (const id of accept)
1475
+ archive.delete(id);
1476
+ return { accept, archive };
1477
+ }
1478
+ // An ask as a reply leaves it. A pick or a write-in replaces whatever was
1479
+ // answered before; a note alone keeps that answer and adds to it.
1480
+ function sentAsk(a, draft) {
1481
+ const answer = draft.answers[a.id];
1482
+ const other = draft.others?.[a.id];
1483
+ const note = draft.notes?.[a.id];
1484
+ if (answer === undefined && other === undefined && note === undefined)
1485
+ return a;
1486
+ const next = { ...a };
1487
+ if (answer !== undefined || other !== undefined) {
1488
+ delete next.answer;
1489
+ delete next.other;
1490
+ if (answer !== undefined)
1491
+ next.answer = answer;
1492
+ if (other !== undefined)
1493
+ next.other = other;
1494
+ }
1495
+ if (note !== undefined)
1496
+ next.note = note;
1497
+ return next;
1498
+ }
1499
+ // Send: the user's one batched answer becomes one kind:"reply" comment on the
1500
+ // mock's session, delivered through the same cursor as everything else. The
1501
+ // draft is cleared and statuses flip in the same store transaction.
1502
+ async function replyFlow(ref, body, ctx) {
1503
+ if (!ctx.viewer)
1504
+ return viewerOnly();
1505
+ const mock = await resolveMock(ref);
1506
+ if (isResult(mock))
1507
+ return mock;
1508
+ const posts = await store.listPosts({ mockId: mock.id });
1509
+ const draft = sanitizeDraft(body ?? {}, mock, posts, mock.draft);
1510
+ if (typeof draft === "string")
1511
+ return fail(400, draft);
1512
+ const text = str(body?.text, MAX_COMMENT_TEXT);
1513
+ const decision = sanitizeDecision(body?.decision, mock, posts);
1514
+ if (typeof decision === "string")
1515
+ return fail(400, decision);
1516
+ const empty = !text &&
1517
+ !decision &&
1518
+ !Object.keys(draft.answers).length &&
1519
+ !Object.keys(draft.mix).length &&
1520
+ !Object.keys(draft.tuned).length &&
1521
+ !draft.comments.length &&
1522
+ !Object.keys(draft.others ?? {}).length &&
1523
+ !Object.keys(draft.notes ?? {}).length;
1524
+ if (empty)
1525
+ return fail(400, "nothing to send");
1526
+ const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
1527
+ const sessionId = mock.sessionId ?? latest?.sessionId;
1528
+ if (!sessionId)
1529
+ return fail(409, `${mock.slug} has no agent session to reply to`);
1530
+ const payload = {
1531
+ mockId: mock.id,
1532
+ version: draft.version,
1533
+ answers: draft.answers,
1534
+ mix: draft.mix,
1535
+ tuned: draft.tuned,
1536
+ comments: draft.comments,
1537
+ ...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
1538
+ ...(Object.keys(draft.notes ?? {}).length ? { notes: draft.notes } : {}),
1539
+ ...(text ? { text } : {}),
1540
+ ...(decision ? { decision } : {}),
1541
+ };
1542
+ const asks = mock.asks.map((a) => sentAsk(a, draft));
1543
+ const flips = replyFlips(mock, posts, draft.answers, decision);
1544
+ const comment = await store.commitReply({
1545
+ mockId: mock.id,
1546
+ sessionId,
1547
+ text: text ?? "",
1548
+ payload,
1549
+ asks,
1550
+ accept: [...flips.accept],
1551
+ archive: [...flips.archive],
1552
+ });
1553
+ if (!comment)
1554
+ return fail(409, `${mock.slug} has no agent session to reply to`);
1555
+ announceComment(comment);
1556
+ bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
1557
+ bus.broadcast({ type: "draft-updated", mockId: mock.id });
1558
+ const after = await store.listPosts({ mockId: mock.id });
1559
+ for (const p of after) {
1560
+ if (flips.accept.has(p.id) || flips.archive.has(p.id)) {
1561
+ bus.broadcast({
1562
+ type: "post-updated",
1563
+ id: p.id,
1564
+ mockId: mock.id,
1565
+ sessionId: p.sessionId,
1566
+ version: p.version,
1567
+ by: "user",
1568
+ });
1569
+ }
1570
+ }
1571
+ fireNotify({
1572
+ event: "decision",
1573
+ project: mock.project,
1574
+ slug: mock.slug,
1575
+ variant: decision?.variant ?? "",
1576
+ version: draft.version,
1577
+ text: text || decision?.kind || "reply",
1578
+ url: mockUrl(ctx.base, mock),
1579
+ });
1580
+ const [delivered] = await withDelivered([comment]);
1581
+ return ok({
1582
+ reply: delivered,
1583
+ accepted: after
1584
+ .filter((p) => flips.accept.has(p.id))
1585
+ .map((p) => ({ state: p.state, variant: p.variant })),
1586
+ archived: after
1587
+ .filter((p) => flips.archive.has(p.id))
1588
+ .map((p) => ({ state: p.state, variant: p.variant })),
1589
+ }, 201);
1590
+ }
1591
+ async function restoreFlow(ref, body) {
1592
+ const mock = await resolveMock(ref, body?.project);
1593
+ if (isResult(mock))
1594
+ return mock;
1595
+ const posts = await store.listPosts({ mockId: mock.id });
1596
+ const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
1597
+ if (isResult(chosen))
1598
+ return chosen;
1599
+ const updated = await store.setPostStatus(chosen.id, "open");
1600
+ if (!updated)
1601
+ return fail(404, "variant not found");
1602
+ bus.broadcast({
1603
+ type: "post-updated",
1604
+ id: updated.id,
1605
+ mockId: mock.id,
1606
+ sessionId: updated.sessionId,
1607
+ version: updated.version,
1608
+ by: "user",
1609
+ });
1610
+ return ok({ state: updated.state, variant: updated.variant, status: updated.status });
1611
+ }
1612
+ // D8 "restore as vN": the user brings an older version back as the newest
1613
+ // one. Distinct from restoreFlow, which un-archives a variant. Viewer-only
1614
+ // because the version is authored by the user; an agent restoring publishes.
1615
+ async function restoreVersionFlow(ref, postId, body, ctx) {
1616
+ if (!ctx.viewer)
1617
+ return viewerOnly();
1618
+ const mock = await resolveMock(ref);
1619
+ if (isResult(mock))
1620
+ return mock;
1621
+ const post = await store.getPost(postId);
1622
+ if (!post || post.mock !== mock.id)
1623
+ return fail(404, `${mock.slug} has no variant "${postId}"`);
1624
+ const version = Number(body?.version);
1625
+ if (!Number.isInteger(version) || version < 1) {
1626
+ return fail(400, '"version" must be a positive integer');
1627
+ }
1628
+ if (version === post.version)
1629
+ return fail(409, `v${version} is already the current version`);
1630
+ const old = post.history.find((h) => h.version === version);
1631
+ if (!old)
1632
+ return fail(404, `v${version} is not available`);
1633
+ const updated = await store.updatePost(post.id, {
1634
+ surfaces: old.surfaces,
1635
+ title: old.title,
1636
+ from: version,
1637
+ prompt: `restored v${version}`,
1638
+ author: "user",
1639
+ });
1640
+ if (!updated)
1641
+ return fail(404, "variant not found");
1642
+ const touched = (await store.updateMock(mock.id, {})) ?? mock;
1643
+ bus.broadcast({ type: "mock-updated", id: touched.id, project: touched.project });
1644
+ announcePost(touched, updated, false, "user");
1645
+ return ok({
1646
+ state: updated.state,
1647
+ variant: updated.variant,
1648
+ version: updated.version,
1649
+ from: version,
1650
+ });
1651
+ }
1652
+ async function removeMockFlow(ref, query) {
1653
+ const mock = await resolveMock(ref, query.project);
1654
+ if (isResult(mock))
1655
+ return mock;
1656
+ await store.removeMock(mock.id);
1657
+ bus.broadcast({ type: "mock-deleted", id: mock.id, project: mock.project });
1658
+ return ok({ ok: true });
1659
+ }
1660
+ // --- comments ---
1661
+ function numberInRange(value, min, max) {
1662
+ const n = Number(value);
1663
+ return Number.isFinite(n) && n >= min && n <= max ? n : null;
1664
+ }
1665
+ function sanitizeCommentAnchor(raw, mock, post) {
1666
+ if (!raw || typeof raw !== "object")
1667
+ return undefined;
1668
+ const input = raw;
1669
+ if (input.kind === "part") {
1670
+ const part = str(input.part, 200);
1671
+ if (!part)
1672
+ return undefined;
1673
+ const state = stateArg(input.state) ?? post?.state ?? null;
1674
+ if (state !== null && !mock.states.includes(state))
1675
+ return undefined;
1676
+ return { kind: "part", part, state, ...sanitizePartAnchor(input) };
1677
+ }
1678
+ if (!post)
1679
+ return undefined;
1680
+ const kind = input.kind === "rect" || input.kind === "lineRange" ? input.kind : "point";
1681
+ let surfaceIndex = Number(input.surfaceIndex);
1682
+ if (!Number.isInteger(surfaceIndex) ||
1683
+ surfaceIndex < 0 ||
1684
+ surfaceIndex >= post.surfaces.length) {
1685
+ const surfaceId = typeof input.surfaceId === "string" ? input.surfaceId : undefined;
1686
+ surfaceIndex = post.surfaces.findIndex((s) => s.id === surfaceId);
1687
+ }
1688
+ if (surfaceIndex < 0 || surfaceIndex >= post.surfaces.length)
1689
+ return undefined;
1690
+ const surface = post.surfaces[surfaceIndex];
1691
+ const base = {
1692
+ surfaceIndex,
1693
+ ...(surface.id && { surfaceId: surface.id }),
1694
+ surfaceKind: surface.kind,
1695
+ // The server pins anchors to the current stored version instead of trusting
1696
+ // the client-supplied value.
1697
+ postVersion: post.version,
1698
+ };
1699
+ if (kind === "lineRange") {
1700
+ const startLine = Number(input.startLine);
1701
+ const endLine = Number(input.endLine);
1702
+ if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1) {
1703
+ return undefined;
1704
+ }
1705
+ return {
1706
+ kind,
1707
+ ...base,
1708
+ startLine,
1709
+ endLine: Math.max(startLine, endLine),
1710
+ ...(typeof input.file === "string" && { file: input.file.slice(0, MAX_TITLE) }),
1711
+ };
1712
+ }
1713
+ const x = numberInRange(input.x, 0, 1);
1714
+ const y = numberInRange(input.y, 0, 1);
1715
+ if (x == null || y == null)
1716
+ return undefined;
1717
+ if (kind === "rect") {
1718
+ const w = numberInRange(input.w, 0, 1);
1719
+ const h = numberInRange(input.h, 0, 1);
1720
+ if (w == null || h == null)
1721
+ return undefined;
1722
+ return { kind, ...base, x, y, w, h };
1723
+ }
1724
+ return { kind: "point", ...base, x, y };
1725
+ }
1726
+ // Viewport presets the stage lays out at; anything else is dropped rather
1727
+ // than echoed back to the agent as a made-up width.
1728
+ const VIEWPORTS = new Set([390, 820, 1280]);
1729
+ const sanitizeViewport = (raw) => {
1730
+ const n = Number(raw);
1731
+ return VIEWPORTS.has(n) ? n : null;
1732
+ };
1733
+ const sanitizePostVersion = (raw, post) => {
1734
+ const n = Number(raw);
1735
+ return Number.isInteger(n) && n >= 1 && n <= post.version ? n : post.version;
1736
+ };
1737
+ // Markers are DATA: every field is re-derived or range-checked here, so
1738
+ // whatever the overlay (or a forged request) sends, the agent only ever
1739
+ // receives a bounded shape it can render as text.
1740
+ const MAX_ANCHORS = 20;
1741
+ const MAX_ANCHOR_TEXT = 200;
1742
+ function sanitizeAnchors(raw, post, viewport) {
1743
+ if (!post || !Array.isArray(raw))
1744
+ return [];
1745
+ const out = [];
1746
+ for (const entry of raw.slice(0, MAX_ANCHORS)) {
1747
+ if (!entry || typeof entry !== "object")
1748
+ continue;
1749
+ const a = entry;
1750
+ const shape = a.shape === "rect" || a.shape === "circle" ? a.shape : "pin";
1751
+ const box = Array.isArray(a.box) ? a.box.map((n) => numberInRange(n, 0, 1)) : [];
1752
+ const need = shape === "pin" ? 2 : 4;
1753
+ if (box.length < need || box.slice(0, need).some((n) => n == null))
1754
+ continue;
1755
+ let surfaceIndex = Number(a.surfaceIndex);
1756
+ if (!Number.isInteger(surfaceIndex) ||
1757
+ surfaceIndex < 0 ||
1758
+ surfaceIndex >= post.surfaces.length) {
1759
+ surfaceIndex = 0;
1760
+ }
1761
+ const ref = typeof a.ref === "string" && /^@\d{1,3}$/.test(a.ref) ? a.ref : `@${out.length + 1}`;
1762
+ out.push({
1763
+ ref,
1764
+ shape,
1765
+ box: box.slice(0, need),
1766
+ surfaceIndex,
1767
+ postVersion: sanitizePostVersion(a.postVersion, post),
1768
+ ...(typeof a.path === "string" && { path: a.path.slice(0, MAX_ANCHOR_TEXT) }),
1769
+ ...(typeof a.text === "string" && { text: a.text.slice(0, MAX_ANCHOR_TEXT) }),
1770
+ ...(viewport === null ? {} : { viewport }),
1771
+ });
1772
+ }
1773
+ return out;
1774
+ }
1775
+ function announceComment(comment) {
1776
+ bus.broadcast({
1777
+ type: "comment-created",
1778
+ id: comment.id,
1779
+ sessionId: comment.sessionId,
1780
+ mockId: comment.mockId,
1781
+ postId: comment.postId,
1782
+ seq: comment.seq,
1783
+ });
1784
+ }
1785
+ // Where a thread comment lands: a mock, optionally one variant of it, and the
1786
+ // session whose stream carries it.
1787
+ async function commentTarget(body) {
1788
+ let post = null;
1789
+ let mock = null;
1790
+ if (typeof body.post === "string" && body.post) {
1791
+ post = await store.getPost(body.post);
1792
+ if (!post)
1793
+ return fail(404, "post not found");
1794
+ mock = await store.getMock(post.mock);
1795
+ }
1796
+ else if (body.mock !== undefined) {
1797
+ const resolved = await resolveMock(body.mock, body.project, body.session);
1798
+ if (isResult(resolved))
1799
+ return resolved;
1800
+ mock = resolved;
1801
+ if (body.variant !== undefined || body.state !== undefined) {
1802
+ const posts = await store.listPosts({ mockId: mock.id });
1803
+ const chosen = chooseVariant(mock, posts, stateArg(body.state), str(body.variant, MAX_LABEL));
1804
+ if (isResult(chosen))
1805
+ return chosen;
1806
+ post = chosen;
1807
+ }
1808
+ }
1809
+ else {
1810
+ return fail(400, 'provide "mock" (or "post")');
1811
+ }
1812
+ if (!mock)
1813
+ return fail(404, "mock not found");
1814
+ let session = null;
1815
+ if (typeof body.session === "string" && body.session) {
1816
+ session = await store.getSession(body.session);
1817
+ if (!session)
1818
+ return fail(404, `session "${body.session}" not found`);
1819
+ }
1820
+ if (!session) {
1821
+ const sid = mock.sessionId ?? post?.sessionId;
1822
+ session = sid ? await store.getSession(sid) : null;
1823
+ }
1824
+ if (!session)
1825
+ return fail(409, `${mock.slug} has no agent session`);
1826
+ return { mock, post, session };
1827
+ }
1828
+ // The user's comment in a thread: the trusted viewer's verb only. Sandboxed
1829
+ // surfaces have opaque origins, so their bridge is stamped "surface" by the
1830
+ // viewer rather than by contained code. An agent writes with say.
1831
+ async function commentFlow(body, ctx) {
1832
+ if (!ctx.viewer)
1833
+ return fail(403, "only the viewer can do this; an agent posts with say");
1834
+ if (!body || typeof body !== "object")
1835
+ return fail(400, "invalid JSON body");
1836
+ const text = str(body.text, MAX_COMMENT_TEXT);
1837
+ if (!text)
1838
+ return fail(400, 'provide non-empty "text"');
1839
+ const target = await commentTarget(body);
1840
+ if (isResult(target))
1841
+ return target;
1842
+ const { mock, post, session } = target;
1843
+ const viewport = sanitizeViewport(body.viewport);
1844
+ const comment = await store.createComment({
1845
+ sessionId: session.id,
1846
+ mockId: mock.id,
1847
+ postId: post?.id ?? null,
1848
+ author: body.author === "surface" ? "surface" : "user",
1849
+ text,
1850
+ anchor: sanitizeCommentAnchor(body.anchor, mock, post),
1851
+ kind: "comment",
1852
+ anchors: sanitizeAnchors(body.anchors, post, viewport),
1853
+ postVersion: post ? sanitizePostVersion(body.postVersion, post) : null,
1854
+ viewport,
1855
+ });
1856
+ if (!comment)
1857
+ return fail(404, "session not found");
1858
+ announceComment(comment);
1859
+ const [delivered] = await withDelivered([comment]);
1860
+ return ok(delivered, 201);
1861
+ }
1862
+ // The agent's plain-text message in a mock's thread. Stored as text and
1863
+ // rendered as a text node, never as HTML. A write, so feedback rides along.
1864
+ async function sayFlow(ref, body) {
1865
+ if (!body || typeof body !== "object")
1866
+ return fail(400, "invalid JSON body");
1867
+ const text = typeof body.message === "string" ? str(body.message, MAX_COMMENT_TEXT) : undefined;
1868
+ if (!text)
1869
+ return fail(400, 'provide non-empty "message" text');
1870
+ const target = await commentTarget({ ...body, mock: ref, post: undefined });
1871
+ if (isResult(target))
1872
+ return target;
1873
+ const { mock, post, session } = target;
1874
+ const comment = await store.createComment({
1875
+ sessionId: session.id,
1876
+ mockId: mock.id,
1877
+ postId: post?.id ?? null,
1878
+ author: reservedAgent(session.agent),
1879
+ text,
1880
+ kind: "comment",
1881
+ anchors: [],
1882
+ postVersion: post ? post.version : null,
1883
+ viewport: null,
1884
+ });
1885
+ if (!comment)
1886
+ return fail(404, "session not found");
1887
+ announceComment(comment);
1888
+ return ok({ feedback: await collectFeedback(comment.sessionId) }, 201);
1889
+ }
1890
+ // Long-poll: resolves as soon as a matching comment lands, or at timeout.
1891
+ async function waitForComments(q, signal) {
1892
+ // An author=user session wait with no explicit cursor resumes from the
1893
+ // session's agentSeq — "where the agent left off" lives server-side so the
1894
+ // CLI, both MCP transports, and piggyback share one exactly-once stream.
1895
+ let afterSeq = q.afterSeq;
1896
+ if (afterSeq === undefined && q.author === "user" && q.sessionId) {
1897
+ afterSeq = (await store.getSession(q.sessionId))?.agentSeq;
1898
+ }
1899
+ const query = { sessionId: q.sessionId, mockId: q.mockId, postId: q.postId, afterSeq };
1900
+ const matches = (list) => q.author ? list.filter((cm) => cm.author === q.author) : list;
1901
+ const wait = Math.min(Math.max(q.waitSeconds, 0), MAX_WAIT_SECONDS);
1902
+ let all = await store.listComments(query);
1903
+ let comments = matches(all);
1904
+ if (comments.length === 0 && wait > 0) {
1905
+ await new Promise((resolve) => {
1906
+ const timer = setTimeout(done, wait * 1000);
1907
+ const unsubscribe = bus.subscribe((event) => {
1908
+ if (event.type !== "comment-created")
1909
+ return;
1910
+ if (q.sessionId && event.sessionId !== q.sessionId)
1911
+ return;
1912
+ if (q.mockId && event.mockId !== q.mockId)
1913
+ return;
1914
+ if (q.postId && event.postId !== q.postId)
1915
+ return;
1916
+ done();
1917
+ });
1918
+ const onAbort = () => done();
1919
+ let settled = false;
1920
+ signal?.addEventListener("abort", onAbort, { once: true });
1921
+ if (signal?.aborted)
1922
+ done();
1923
+ function done() {
1924
+ if (settled)
1925
+ return;
1926
+ settled = true;
1927
+ clearTimeout(timer);
1928
+ unsubscribe();
1929
+ signal?.removeEventListener("abort", onAbort);
1930
+ resolve();
1931
+ }
1932
+ });
1933
+ // A caller that gave up can't receive this batch, so delivering it would
1934
+ // advance the cursor past feedback no agent ever saw.
1935
+ if (signal?.aborted)
1936
+ return { comments: [], lastSeq: afterSeq ?? 0 };
1937
+ all = await store.listComments(query);
1938
+ comments = matches(all);
1939
+ }
1940
+ // The cursor advances past every comment in the window — not just the
1941
+ // filtered ones — so the next call doesn't re-read the agent's own comments.
1942
+ const lastSeq = all.length > 0 ? all[all.length - 1].seq : (afterSeq ?? 0);
1943
+ // An author=user query is the agent listening (the viewer never filters by
1944
+ // author) — what it receives here must not be re-delivered as piggyback.
1945
+ if (q.agent && q.author === "user" && q.sessionId && all.length > 0) {
1946
+ await store.markAgentSeen(q.sessionId, lastSeq);
1947
+ bus.broadcast({ type: "comment-delivered", sessionId: q.sessionId, seq: lastSeq });
1948
+ }
1949
+ return { comments, lastSeq };
1950
+ }
1951
+ // The watch long-poll's answer: comments plus the batched feedback built
1952
+ // from them.
1953
+ async function listenFlow(q, signal) {
1954
+ const result = await waitForComments({ ...q, agent: true }, signal);
1955
+ const feedback = await buildFeedbackBatches(store, result.comments);
1956
+ return ok({ ...result, feedback });
1957
+ }
1958
+ // What the user is doing right now, per mock: derived from the SSE registry
1959
+ // and the server-side draft, never stored. It is why an empty `feedback` still
1960
+ // tells the agent something ("2 of 3 answered", or nobody is looking).
1961
+ const pendingOf = (mock, posts) => ({
1962
+ mock: mock.slug,
1963
+ viewerOpen: viewerClients > 0,
1964
+ draft: mock.draft
1965
+ ? {
1966
+ answered: Object.keys(mock.draft.answers).length,
1967
+ of: openCount(mock, posts),
1968
+ comments: mock.draft.comments.length,
1969
+ touchedAt: mock.draft.updatedAt,
1970
+ }
1971
+ : null,
1972
+ });
1973
+ // The feedback verb: never blocks. Takes what the session has not heard yet
1974
+ // (the same cursor as piggyback and watch) plus `pending` for the project.
1975
+ async function feedbackFlow(query) {
1976
+ if (typeof query.session !== "string" || !query.session) {
1977
+ return fail(400, 'provide "session": the session id a write returned');
1978
+ }
1979
+ const session = await store.getSession(query.session);
1980
+ if (!session)
1981
+ return fail(404, `session "${query.session}" not found`);
1982
+ const { comments } = await waitForComments({
1983
+ sessionId: session.id,
1984
+ author: "user",
1985
+ waitSeconds: 0,
1986
+ agent: true,
1987
+ });
1988
+ const project = str(query.project, MAX_TITLE) ?? session.project ?? undefined;
1989
+ return ok({
1990
+ feedback: await buildFeedbackBatches(store, comments),
1991
+ pending: await Promise.all((await store.listMocks(project)).map(async (m) => pendingOf(m, await store.listPosts({ mockId: m.id })))),
1992
+ });
1993
+ }
1994
+ // Store an uploaded blob. An explicit session is validated and a missing one
1995
+ // is auto-created so an upload can precede the first publish. The asset's
1996
+ // data is dropped from the result (it's bytes).
1997
+ async function uploadAsset(input) {
1998
+ if (input.data.byteLength === 0)
1999
+ return { error: "empty upload", status: 400 };
2000
+ if (input.data.byteLength > MAX_ASSET_BYTES) {
2001
+ return { error: `asset exceeds ${MAX_ASSET_BYTES} bytes`, status: 413 };
2002
+ }
2003
+ let sessionId = input.session;
2004
+ if (sessionId && !(await store.getSession(sessionId))) {
2005
+ return { error: `session "${sessionId}" not found`, status: 404 };
2006
+ }
2007
+ if (!sessionId) {
2008
+ const session = await store.createSession({ agent: input.agent ?? "agent" });
2009
+ bus.broadcast({ type: "session-created", id: session.id });
2010
+ sessionId = session.id;
2011
+ }
2012
+ const asset = await store.putAsset({
2013
+ sessionId,
2014
+ kind: input.kind ?? inferAssetKind(input.contentType),
2015
+ contentType: input.contentType || "application/octet-stream",
2016
+ filename: input.filename,
2017
+ data: input.data,
2018
+ });
2019
+ if (!asset)
2020
+ return { error: "session not found", status: 404 };
2021
+ const { data: _data, ...meta } = asset;
2022
+ return { asset: meta };
2023
+ }
2024
+ // --- auth ---
2025
+ const isAuthenticated = (c) => {
2026
+ if (!authToken)
2027
+ return true;
2028
+ if (c.req.header("authorization") === `Bearer ${authToken}`)
2029
+ return true;
2030
+ if (getCookie(c, "mockpit_key") === authToken)
2031
+ return true;
2032
+ return c.req.query("key") === authToken;
2033
+ };
2034
+ const isUnauthenticatedSessionRead = (c) => publicRead === "session" && !isAuthenticated(c);
2035
+ app.use("*", async (c, next) => {
2036
+ const path = new URL(c.req.url).pathname;
2037
+ // Content-hashed static assets (the surface bridge script and the static
2038
+ // stylesheets) are served unauthenticated on purpose: they are our own code,
2039
+ // carry no workspace data, and the sandboxed frames that load them run at an
2040
+ // opaque origin, so their subresource requests carry no SameSite cookie and
2041
+ // could not authenticate even on a tokened workspace.
2042
+ if (path.startsWith(STATIC_ASSET_PREFIX))
2043
+ return next();
2044
+ if (authenticate) {
2045
+ const result = await authenticate(c.req.raw);
2046
+ if (result === true)
2047
+ return next();
2048
+ if (result instanceof Response)
2049
+ return result;
2050
+ if (path.startsWith("/api") || path === "/mcp") {
2051
+ return c.json({ error: "unauthorized" }, 401);
2052
+ }
2053
+ return c.text("unauthorized", 401);
2054
+ }
2055
+ if (!authToken)
2056
+ return next();
2057
+ if (path === "/guide" || path === "/setup" || path === "/agent-howto")
2058
+ return next();
2059
+ if (path.startsWith(STATIC_ASSET_PREFIX))
2060
+ return next();
2061
+ const key = c.req.query("key");
2062
+ if (key === authToken) {
2063
+ setCookie(c, "mockpit_key", authToken, {
2064
+ httpOnly: true,
2065
+ sameSite: "Lax",
2066
+ secure: new URL(c.req.url).protocol === "https:",
2067
+ maxAge: 60 * 60 * 24 * 90,
2068
+ path: "/",
2069
+ });
2070
+ return next();
2071
+ }
2072
+ if (publicRead &&
2073
+ c.req.method === "GET" &&
2074
+ isPublicReadAllowed(path, publicRead, new URL(c.req.url).searchParams)) {
2075
+ return next();
2076
+ }
2077
+ if (isAuthenticated(c))
2078
+ return next();
2079
+ if (path.startsWith("/api") || path === "/mcp") {
2080
+ return c.json({ error: "unauthorized — send Authorization: Bearer <token>" }, 401);
2081
+ }
2082
+ return c.text("unauthorized — open this page as /?key=<your token>", 401);
2083
+ });
2084
+ // Cap every request body. Runs after auth, so an unauthenticated request on a
2085
+ // token-protected workspace is rejected (401) before its body is ever read; on a
2086
+ // no-token workspace it still bounds the body. bodyLimit short-circuits on an
2087
+ // oversize Content-Length and otherwise streams-and-aborts at the cap, so a
2088
+ // chunked body (no Content-Length) can't slip past either. /api/assets is
2089
+ // exempt here because it applies its own, stricter cap (limitAssetBody below).
2090
+ const limitBody = bodyLimit({
2091
+ maxSize: MAX_BODY_BYTES,
2092
+ onError: (c) => c.json({ error: "request body too large" }, 413),
2093
+ });
2094
+ app.use("*", (c, next) => (c.req.path === "/api/assets" ? next() : limitBody(c, next)));
2095
+ // The asset route's own (tighter) body cap. Keeps the asset limit and its
2096
+ // wording, and bounds the upload before it is read — bodyLimit refuses an
2097
+ // oversize Content-Length outright and aborts a chunked stream at the cap.
2098
+ const limitAssetBody = bodyLimit({
2099
+ maxSize: MAX_ASSET_BYTES,
2100
+ onError: (c) => c.json({ error: `asset exceeds ${MAX_ASSET_BYTES} bytes` }, 413),
2101
+ });
2102
+ const withOrigin = (text, c) => text.replaceAll(LOCAL_ORIGIN, new URL(c.req.url).origin);
2103
+ const injectHead = (text, head) => {
2104
+ const headClose = text.lastIndexOf("</head>");
2105
+ return headClose >= 0
2106
+ ? `${text.slice(0, headClose)}${head}${text.slice(headClose)}`
2107
+ : `${head}${text}`;
2108
+ };
2109
+ const withDocumentTitle = (text, title) => {
2110
+ if (!title)
2111
+ return text;
2112
+ const escaped = escapeHtml(title);
2113
+ const titleTag = `<title>${escaped}</title>`;
2114
+ const titleStart = text.indexOf("<title>");
2115
+ if (titleStart < 0)
2116
+ return injectHead(text, titleTag);
2117
+ const titleEnd = text.indexOf("</title>", titleStart + "<title>".length);
2118
+ if (titleEnd < 0)
2119
+ return injectHead(text, titleTag);
2120
+ return `${text.slice(0, titleStart)}${titleTag}${text.slice(titleEnd + "</title>".length)}`;
2121
+ };
2122
+ const withViewerConfig = (text, request, isReadonly, pageTitle) => {
2123
+ const config = [
2124
+ `window.__MOCKPIT_BASE_PATH__=${JSON.stringify(requestBasePath(request))};`,
2125
+ pageTitle ? `window.__MOCKPIT_PAGE_TITLE__=${JSON.stringify(pageTitle)};` : "",
2126
+ isReadonly ? "window.__MOCKPIT_READONLY__=true;" : "",
2127
+ isReadonly && publicRead
2128
+ ? `window.__MOCKPIT_PUBLIC_READ__=${JSON.stringify(publicRead)};`
2129
+ : "",
2130
+ screenshots ? "window.__MOCKPIT_SCREENSHOTS__=true;" : "",
2131
+ ].join("");
2132
+ return injectHead(text, `<script>${config}</script>`);
2133
+ };
2134
+ // --- viewer pages ---
2135
+ // Link previews for a mock page: the first open variant's first surface,
2136
+ // with every pixel-affecting input pinned in the image URL so the Worker can
2137
+ // cache it at the edge.
2138
+ const mockPreviewHead = (mock, post, request, mode, rendererGeneration) => {
2139
+ const origin = new URL(request.url).origin;
2140
+ const publicBasePath = requestBasePath(request);
2141
+ const canonical = `${origin}${publicBasePath}/project/${encodeURIComponent(mock.project)}/${encodeURIComponent(mock.slug)}`;
2142
+ const imageUrl = new URL(`${origin}${publicBasePath}/s/${post.id}.png`);
2143
+ imageUrl.searchParams.set("card", "1");
2144
+ imageUrl.searchParams.set("theme", DEFAULT_THEME_ID);
2145
+ imageUrl.searchParams.set("mode", mode);
2146
+ imageUrl.searchParams.set("v", String(post.version));
2147
+ imageUrl.searchParams.set("g", rendererGeneration);
2148
+ const image = imageUrl.toString();
2149
+ const title = escapeHtml(mock.title);
2150
+ const description = "A https://mockpit.sh mock";
2151
+ return [
2152
+ `<link rel="canonical" href="${escapeHtml(canonical)}">`,
2153
+ `<meta property="og:type" content="website">`,
2154
+ `<meta property="og:title" content="${title}">`,
2155
+ `<meta property="og:description" content="${description}">`,
2156
+ `<meta property="og:url" content="${escapeHtml(canonical)}">`,
2157
+ `<meta property="og:image" content="${escapeHtml(image)}">`,
2158
+ `<meta property="og:image:width" content="1200">`,
2159
+ `<meta property="og:image:height" content="630">`,
2160
+ `<meta name="twitter:card" content="summary_large_image">`,
2161
+ `<meta name="twitter:title" content="${title}">`,
2162
+ `<meta name="twitter:description" content="${description}">`,
2163
+ `<meta name="twitter:image" content="${escapeHtml(image)}">`,
2164
+ ].join("\n");
2165
+ };
2166
+ const configuredViewerHtml = async (c, opts = {}) => {
2167
+ // The viewer HTML is the trusted app origin — it shares that origin with the
2168
+ // authenticated API and the comment→agent channel, so a cross-origin page
2169
+ // that frames it could clickjack actions or the prompt-injection channel.
2170
+ // Refuse cross-origin framing (same-origin embedding still allowed). The
2171
+ // sandboxed surface documents at /s/:id are *meant* to be framed and carry
2172
+ // their own `sandbox` CSP header instead, so they never pass through here.
2173
+ c.header("Content-Security-Policy", "frame-ancestors 'self'");
2174
+ const html = withDocumentTitle(withViewerConfig(withOrigin(viewerHtml, { req: { url: c.req.url } }), c.req.raw, !!publicRead && !isAuthenticated(c), opts.title), opts.title);
2175
+ if (!opts.preview)
2176
+ return html;
2177
+ const { mock, post } = opts.preview;
2178
+ return injectHead(html, mockPreviewHead(mock, post, c.req.raw, await workspaceMode(), version ?? "dev"));
2179
+ };
2180
+ app.get("/", async (c) => c.html(await configuredViewerHtml(c)));
2181
+ // The engine reads the project/mock out of the URL itself; these render the
2182
+ // same shell as "/".
2183
+ app.get("/project/:name", async (c) => c.html(await configuredViewerHtml(c, { title: c.req.param("name") })));
2184
+ app.get("/project/:name/:slug", async (c) => {
2185
+ const mock = await store.findMock(c.req.param("name"), c.req.param("slug"));
2186
+ if (!mock)
2187
+ return c.html(await configuredViewerHtml(c, { title: c.req.param("name") }));
2188
+ const posts = await store.listPosts({ mockId: mock.id });
2189
+ const post = posts.find((p) => p.status !== "archived") ?? posts[0];
2190
+ return c.html(await configuredViewerHtml(c, {
2191
+ title: mock.title,
2192
+ ...(post ? { preview: { mock, post } } : {}),
2193
+ }));
2194
+ });
2195
+ // --- REST plumbing for the shared flows ---
2196
+ const flowCtx = (c, strict = true) => ({
2197
+ base: `${new URL(c.req.url).origin}${requestBasePath(c.req.raw)}`,
2198
+ // The browser sets Fetch Metadata on same-origin requests; only the trusted
2199
+ // viewer may act as the user (drafts, replies, user-authored comments).
2200
+ viewer: c.req.header("sec-fetch-site") === "same-origin",
2201
+ strict,
2202
+ request: c.req.raw,
2203
+ signal: c.req.raw.signal,
2204
+ });
2205
+ const send = (c, result) => c.json(result.body, result.status);
2206
+ const jsonBody = (c) => c.req.json().catch(() => null);
2207
+ const flag = (c, key) => c.req.query(key) === "1";
2208
+ // --- sessions ---
2209
+ app.get("/api/sessions", async (c) => {
2210
+ const [sessions, counts] = await Promise.all([
2211
+ store.listSessions(),
2212
+ store.countPostsBySession(),
2213
+ ]);
2214
+ return c.json(sessions.map((s) => sessionRowView(s, counts.get(s.id) ?? 0)));
2215
+ });
2216
+ app.get("/api/sessions/:id", async (c) => {
2217
+ const session = await store.getSession(c.req.param("id"));
2218
+ if (!session)
2219
+ return c.json({ error: "session not found" }, 404);
2220
+ const posts = await store.listPosts({ sessionId: session.id });
2221
+ return c.json(sessionRowView(session, posts.length));
2222
+ });
2223
+ app.post("/api/sessions", async (c) => {
2224
+ const body = (await jsonBody(c)) ?? {};
2225
+ const cwd = str(body.cwd, 4096);
2226
+ const key = str(body.key, MAX_TITLE);
2227
+ const existing = key ? await store.getSessionByKey(key) : null;
2228
+ if (existing)
2229
+ return c.json(existing, 200);
2230
+ const session = await store.createSession({
2231
+ agent: str(body.agent, MAX_TITLE) ?? "agent",
2232
+ title: str(body.title, MAX_TITLE),
2233
+ cwd,
2234
+ // Resolved once here so every mock this session publishes lands in the
2235
+ // same project.
2236
+ project: resolveProject(str(body.project, MAX_TITLE), cwd),
2237
+ key,
2238
+ });
2239
+ bus.broadcast({ type: "session-created", id: session.id });
2240
+ return c.json(session, 201);
2241
+ });
2242
+ app.patch("/api/sessions/:id", async (c) => {
2243
+ const body = await jsonBody(c);
2244
+ if (!body || typeof body.title !== "string") {
2245
+ return c.json({ error: 'body must include "title" string' }, 400);
2246
+ }
2247
+ const session = await store.renameSession(c.req.param("id"), body.title.slice(0, MAX_TITLE));
2248
+ if (!session)
2249
+ return c.json({ error: "session not found" }, 404);
2250
+ bus.broadcast({ type: "session-updated", id: session.id });
2251
+ return c.json(session);
2252
+ });
2253
+ app.delete("/api/sessions/:id", async (c) => {
2254
+ const id = c.req.param("id");
2255
+ if (!(await store.removeSession(id)))
2256
+ return c.json({ error: "session not found" }, 404);
2257
+ bus.broadcast({ type: "session-deleted", id });
2258
+ return c.json({ ok: true });
2259
+ });
2260
+ // --- projects and mocks ---
2261
+ app.get("/api/projects", async (c) => c.json(await store.listProjects()));
2262
+ app.get("/api/mocks", async (c) => send(c, await listMocksFlow({ project: c.req.query("project") })));
2263
+ app.post("/api/mocks", async (c) => send(c, await publishFlow(await jsonBody(c), flowCtx(c))));
2264
+ // A session-scoped public read may only address a mock by its unguessable id.
2265
+ const guardPublicMock = async (c) => {
2266
+ if (!isUnauthenticatedSessionRead(c))
2267
+ return null;
2268
+ return (await store.getMock(c.req.param("id") ?? ""))
2269
+ ? null
2270
+ : c.json({ error: "mock not found" }, 404);
2271
+ };
2272
+ app.get("/api/mocks/:id", async (c) => {
2273
+ const denied = await guardPublicMock(c);
2274
+ if (denied)
2275
+ return denied;
2276
+ return send(c, await getMockFlow(c.req.param("id"), {
2277
+ project: c.req.query("project"),
2278
+ body: flag(c, "body"),
2279
+ history: flag(c, "history"),
2280
+ }));
2281
+ });
2282
+ app.get("/api/mocks/:id/export", async (c) => {
2283
+ const denied = await guardPublicMock(c);
2284
+ if (denied)
2285
+ return denied;
2286
+ return send(c, await exportFlow(c.req.param("id"), {
2287
+ project: c.req.query("project"),
2288
+ state: c.req.query("state"),
2289
+ variant: c.req.query("variant"),
2290
+ }, flowCtx(c)));
2291
+ });
2292
+ app.delete("/api/mocks/:id", async (c) => send(c, await removeMockFlow(c.req.param("id"), { project: c.req.query("project") })));
2293
+ app.post("/api/mocks/:id/asks", async (c) => send(c, await askFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2294
+ app.get("/api/mocks/:id/draft", async (c) => send(c, await getDraftFlow(c.req.param("id"), flowCtx(c))));
2295
+ app.put("/api/mocks/:id/draft", async (c) => send(c, await putDraftFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2296
+ app.delete("/api/mocks/:id/draft", async (c) => send(c, await deleteDraftFlow(c.req.param("id"), flowCtx(c))));
2297
+ app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2298
+ app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
2299
+ app.post("/api/mocks/:id/variants/:postId/restore", async (c) => send(c, await restoreVersionFlow(c.req.param("id"), c.req.param("postId"), await jsonBody(c), flowCtx(c))));
2300
+ app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c))));
2301
+ app.get("/api/feedback", async (c) => send(c, await feedbackFlow({ session: c.req.query("session"), project: c.req.query("project") })));
2302
+ // --- comments ---
2303
+ app.post("/api/comments", async (c) => send(c, await commentFlow(await jsonBody(c), flowCtx(c))));
2304
+ app.delete("/api/comments/:id", async (c) => {
2305
+ const comment = await store.removeComment(c.req.param("id"));
2306
+ if (!comment)
2307
+ return c.json({ error: "comment not found" }, 404);
2308
+ bus.broadcast({ type: "comment-deleted", id: comment.id, sessionId: comment.sessionId });
2309
+ return c.json({ ok: true });
2310
+ });
2311
+ // The viewer's update notice: running version vs latest published release.
2312
+ app.get("/api/version", async (c) => {
2313
+ if (!version)
2314
+ return c.json({ current: null, latest: null, updateAvailable: false });
2315
+ const latest = await latestRelease();
2316
+ const updateAvailable = latest !== null && versionGt(latest.version, version);
2317
+ return c.json({
2318
+ current: version,
2319
+ latest: latest?.version ?? null,
2320
+ updateAvailable,
2321
+ upgradeCommand: updateAvailable ? (upgradeCommand ?? null) : null,
2322
+ notes: updateAvailable ? (latest?.notes ?? null) : null,
2323
+ });
2324
+ });
2325
+ // Long-poll friendly: ?wait=N holds the request open up to N seconds until
2326
+ // a matching comment arrives. Plumbing for `mockpit watch` and the viewer;
2327
+ // agents read with GET /api/feedback, which never blocks.
2328
+ // A wait counts against the connection cap (it pins a socket just like SSE);
2329
+ // an instant ?wait=0 read does not.
2330
+ app.get("/api/comments", async (c) => {
2331
+ const sessionId = c.req.query("session");
2332
+ const mockId = c.req.query("mock");
2333
+ const postId = c.req.query("post");
2334
+ if (isUnauthenticatedSessionRead(c)) {
2335
+ if (!sessionId && !mockId && !postId) {
2336
+ return c.json({ error: "session, mock or post required" }, 401);
2337
+ }
2338
+ if (sessionId && !(await store.getSession(sessionId))) {
2339
+ return c.json({ error: "session not found" }, 404);
2340
+ }
2341
+ if (mockId && !(await store.getMock(mockId)))
2342
+ return c.json({ error: "mock not found" }, 404);
2343
+ if (postId && !(await store.getPost(postId)))
2344
+ return c.json({ error: "post not found" }, 404);
2345
+ }
2346
+ const waitSeconds = Number(c.req.query("wait") ?? 0) || 0;
2347
+ const author = c.req.query("author");
2348
+ const after = c.req.query("after");
2349
+ const query = {
2350
+ sessionId,
2351
+ mockId,
2352
+ postId,
2353
+ author,
2354
+ afterSeq: after ? Number(after) : undefined,
2355
+ waitSeconds,
2356
+ };
2357
+ // An `author=user` read (or any wait) is the agent listening; anything else
2358
+ // is the viewer reading a thread, which gets per-comment delivery state.
2359
+ // Only an authenticated caller can be the agent: on a public-read workspace
2360
+ // an anonymous reader must never advance the feedback cursor, or the agent
2361
+ // would silently lose the comments that reader "took".
2362
+ const isAgentRead = isAuthenticated(c) && (author === "user" || waitSeconds > 0);
2363
+ query.agent = isAgentRead;
2364
+ const respond = async (signal) => {
2365
+ if (isAgentRead)
2366
+ return send(c, await listenFlow(query, signal));
2367
+ const result = await waitForComments(query, signal);
2368
+ return c.json({ ...result, comments: await withDelivered(result.comments) });
2369
+ };
2370
+ if (waitSeconds <= 0)
2371
+ return respond();
2372
+ if (!acquireHold())
2373
+ return c.json({ error: "too many concurrent connections" }, 503);
2374
+ const release = makeRelease();
2375
+ // If the client disconnects mid-wait, release the slot promptly.
2376
+ c.req.raw.signal.addEventListener("abort", release, { once: true });
2377
+ try {
2378
+ return await respond(c.req.raw.signal);
2379
+ }
2380
+ finally {
2381
+ release();
2382
+ }
2383
+ });
2384
+ // Content-hashed bridge/token assets the surface documents reference, so
2385
+ // every surface doesn't re-inline the same ~11 KB. Without this route the
2386
+ // in-frame bridge never loads and iframes never report their height.
2387
+ app.get("/asset/:file", (c) => {
2388
+ const asset = staticAsset(`/asset/${c.req.param("file")}`);
2389
+ if (!asset)
2390
+ return c.text("Not found", 404);
2391
+ c.header("Content-Type", asset.contentType);
2392
+ c.header("Cache-Control", "public, max-age=31536000, immutable");
2393
+ return c.body(asset.body);
2394
+ });
2395
+ app.get("/guide", (c) => c.text(withOrigin(topics.html ?? "", c)));
2396
+ app.get("/setup", (c) => c.text(withOrigin(setupText, c)));
2397
+ // The brief without `?topic=`, one reference topic with it. Unknown topics
2398
+ // are a 400 that names the real ones, so an agent can correct itself.
2399
+ app.get("/agent-howto", async (c) => {
2400
+ const topic = c.req.query("topic");
2401
+ if (topic === undefined)
2402
+ return c.text(withOrigin(await briefFor(c.req.query("project")), c));
2403
+ if (!isGuideTopic(topic))
2404
+ return c.json({ error: unknownTopicMessage(topic), topics: GUIDE_TOPICS }, 400);
2405
+ return c.text(withOrigin(topics[topic] ?? "", c));
2406
+ });
2407
+ // Opt-in html kits available on this workspace (id, label, summary, classes) —
2408
+ // for discovery (`mockpit kits`); the CSS/JS payloads are server-only. With
2409
+ // `?project=`, that project's own kits follow the bundled ones.
2410
+ app.get("/api/kits", async (c) => {
2411
+ const project = c.req.query("project");
2412
+ return c.json(kitSummaries(project ? ((await designFor(project))?.projectKits ?? []) : []));
2413
+ });
2414
+ // --- theme: one palette, so the workspace setting is just its mode ---
2415
+ app.get("/api/theme", async (c) => c.json({ mode: await workspaceMode() }));
2416
+ app.put("/api/theme", async (c) => {
2417
+ const body = await c.req.json().catch(() => null);
2418
+ const mode = body?.mode;
2419
+ if (!isMode(mode))
2420
+ return c.json({ error: 'mode must be "dark" or "light"' }, 400);
2421
+ await store.setSetting("mode", mode);
2422
+ bus.broadcast({ type: "theme-changed", mode });
2423
+ return c.json({ mode });
2424
+ });
2425
+ // The project's design system, imported by `mockpit init` and injected into
2426
+ // every html surface of the project (see renderHtmlPage).
2427
+ app.get("/api/projects/:name/design", async (c) => c.json(await designFor(c.req.param("name"))));
2428
+ app.put("/api/projects/:name/design", async (c) => {
2429
+ const body = await c.req.json().catch(() => null);
2430
+ if (!body || typeof body !== "object")
2431
+ return c.json({ error: "invalid JSON body" }, 400);
2432
+ const project = c.req.param("name");
2433
+ // `mockpit init` re-detects the repo and PUTs without iconSets; that must
2434
+ // not uninstall what `mockpit icons add` installed.
2435
+ const previous = await designFor(project);
2436
+ let iconSets = previous?.iconSets ?? [];
2437
+ if (body.iconSets !== undefined) {
2438
+ const checked = await checkIconSets(body.iconSets);
2439
+ if (typeof checked === "string")
2440
+ return c.json({ error: checked }, 400);
2441
+ iconSets = checked;
2442
+ }
2443
+ // Same rule as iconSets: a PUT that omits projectKits keeps them.
2444
+ let projectKits = previous?.projectKits ?? [];
2445
+ if (body.projectKits !== undefined) {
2446
+ const checked = checkProjectKits(body.projectKits);
2447
+ if (typeof checked === "string")
2448
+ return c.json({ error: checked }, 400);
2449
+ projectKits = checked;
2450
+ }
2451
+ const kitIds = ["tailwind", "none", ...KIT_IDS, ...projectKits.map((k) => k.id)];
2452
+ const kit = body.kit === undefined || body.kit === null ? "none" : body.kit;
2453
+ if (typeof kit !== "string" || !kitIds.includes(kit))
2454
+ return c.json({ error: `unknown kit "${String(kit)}" — known: ${kitIds.join(", ")}` }, 400);
2455
+ const designFiles = checkDesignFiles(body.designFiles);
2456
+ if (typeof designFiles === "string")
2457
+ return c.json({ error: designFiles }, 400);
2458
+ const design = {
2459
+ detected: body.detected && typeof body.detected === "object"
2460
+ ? {
2461
+ tailwind: body.detected.tailwind === true,
2462
+ shadcn: body.detected.shadcn === true,
2463
+ cssVars: Number(body.detected.cssVars) || 0,
2464
+ fonts: Array.isArray(body.detected.fonts)
2465
+ ? body.detected.fonts.filter((f) => typeof f === "string").slice(0, 20)
2466
+ : [],
2467
+ }
2468
+ : null,
2469
+ palette: body.palette && typeof body.palette === "object" && body.palette.light && body.palette.dark
2470
+ ? body.palette
2471
+ : null,
2472
+ kit,
2473
+ cssVars: typeof body.cssVars === "string" ? body.cssVars.slice(0, 64_000) : "",
2474
+ // A cut stylesheet would not compile, so an oversized one is dropped
2475
+ // whole and the frame falls back to `cssVars`.
2476
+ tailwindCss: typeof body.tailwindCss === "string" && body.tailwindCss.length <= 128_000
2477
+ ? body.tailwindCss
2478
+ : "",
2479
+ strippedImports: Array.isArray(body.strippedImports)
2480
+ ? body.strippedImports
2481
+ .filter((s) => typeof s === "string")
2482
+ .map((s) => s.slice(0, 200))
2483
+ .slice(0, 50)
2484
+ : [],
2485
+ iconSets,
2486
+ projectKits,
2487
+ designFiles,
2488
+ updatedAt: new Date().toISOString(),
2489
+ };
2490
+ return c.json(await saveDesign(project, design));
2491
+ });
2492
+ async function saveDesign(project, design) {
2493
+ await store.setSetting(`design:${project}`, JSON.stringify(design));
2494
+ // Every rendered surface bakes the design into its document string, so a
2495
+ // design change invalidates them all.
2496
+ clearRenderCache();
2497
+ bus.broadcast({ type: "theme-changed", mode: await workspaceMode() });
2498
+ return design;
2499
+ }
2500
+ const MAX_PROJECT_KITS = 16;
2501
+ function checkProjectKits(raw) {
2502
+ if (!Array.isArray(raw))
2503
+ return "projectKits must be an array";
2504
+ if (raw.length > MAX_PROJECT_KITS)
2505
+ return `at most ${MAX_PROJECT_KITS} project kits`;
2506
+ const kits = [];
2507
+ for (const item of raw) {
2508
+ const kit = checkProjectKit(item);
2509
+ if (typeof kit === "string")
2510
+ return kit;
2511
+ if (kits.some((k) => k.id === kit.id))
2512
+ return `kit "${kit.id}" is listed twice`;
2513
+ kits.push(kit);
2514
+ }
2515
+ return kits;
2516
+ }
2517
+ const emptyDesign = () => ({
2518
+ detected: null,
2519
+ palette: null,
2520
+ kit: "none",
2521
+ cssVars: "",
2522
+ tailwindCss: "",
2523
+ strippedImports: [],
2524
+ iconSets: [],
2525
+ projectKits: [],
2526
+ updatedAt: new Date().toISOString(),
2527
+ });
2528
+ // Add or replace one project kit (`mockpit kit add`). A project that never ran
2529
+ // init gets a design holding just the kit.
2530
+ app.put("/api/projects/:name/kits/:id", async (c) => {
2531
+ const body = await c.req.json().catch(() => null);
2532
+ if (!body || typeof body !== "object")
2533
+ return c.json({ error: "invalid JSON body" }, 400);
2534
+ const project = c.req.param("name");
2535
+ const kit = checkProjectKit({ ...body, id: c.req.param("id") });
2536
+ if (typeof kit === "string")
2537
+ return c.json({ error: kit }, 400);
2538
+ const design = (await designFor(project)) ?? emptyDesign();
2539
+ const others = design.projectKits.filter((k) => k.id !== kit.id);
2540
+ if (others.length >= MAX_PROJECT_KITS)
2541
+ return c.json({ error: `at most ${MAX_PROJECT_KITS} project kits` }, 400);
2542
+ const saved = await saveDesign(project, {
2543
+ ...design,
2544
+ projectKits: [...others, kit],
2545
+ updatedAt: new Date().toISOString(),
2546
+ });
2547
+ return c.json(saved);
2548
+ });
2549
+ // Removing the project's default kit falls back to no kit rather than leaving
2550
+ // design.kit naming something that no longer resolves.
2551
+ app.delete("/api/projects/:name/kits/:id", async (c) => {
2552
+ const project = c.req.param("name");
2553
+ const id = c.req.param("id");
2554
+ const design = await designFor(project);
2555
+ if (!design?.projectKits.some((k) => k.id === id))
2556
+ return c.json({ error: `${project} has no kit "${id}"` }, 404);
2557
+ const saved = await saveDesign(project, {
2558
+ ...design,
2559
+ kit: design.kit === id ? "none" : design.kit,
2560
+ projectKits: design.projectKits.filter((k) => k.id !== id),
2561
+ updatedAt: new Date().toISOString(),
2562
+ });
2563
+ return c.json(saved);
2564
+ });
2565
+ // Every set an html surface of this project can name, installed first.
2566
+ app.get("/api/projects/:name/icons", async (c) => {
2567
+ const sets = await iconSetsFor(await designFor(c.req.param("name")));
2568
+ return c.json({
2569
+ sets: sets.map((s) => ({ prefix: s.prefix, count: iconCount(s.set), source: s.source })),
2570
+ });
2571
+ });
2572
+ // The repo's design files as init read them: every map and list is cut to
2573
+ // the reader's own caps, then the whole is refused past 32k so one project
2574
+ // cannot bloat every brief and every settings read.
2575
+ const DESIGN_FILES_MAX = 32_000;
2576
+ const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
2577
+ function checkDesignFiles(raw) {
2578
+ if (raw === undefined || raw === null)
2579
+ return null;
2580
+ if (!isRecord(raw))
2581
+ return "designFiles must be an object";
2582
+ const str = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
2583
+ const strs = (v, n, max) => Array.isArray(v)
2584
+ ? v
2585
+ .filter((s) => typeof s === "string")
2586
+ .map((s) => s.slice(0, max))
2587
+ .slice(0, n)
2588
+ : [];
2589
+ const flat = (v, n) => Object.fromEntries(Object.entries(isRecord(v) ? v : {})
2590
+ .filter((e) => typeof e[1] === "string")
2591
+ .slice(0, n)
2592
+ .map(([k, x]) => [k.slice(0, 80), x.slice(0, 120)]));
2593
+ const nested = (v, n) => Object.fromEntries(Object.entries(isRecord(v) ? v : {})
2594
+ .filter(([, x]) => isRecord(x))
2595
+ .slice(0, n)
2596
+ .map(([k, x]) => [k.slice(0, 80), flat(x, 12)]));
2597
+ const out = {};
2598
+ const md = raw.designMd;
2599
+ if (isRecord(md)) {
2600
+ out.designMd = {
2601
+ name: str(md.name, 120),
2602
+ colors: flat(md.colors, 100),
2603
+ typography: nested(md.typography, 40),
2604
+ rounded: flat(md.rounded, 30),
2605
+ spacing: flat(md.spacing, 30),
2606
+ components: nested(md.components, 60),
2607
+ headings: strs(md.headings, 20, 120),
2608
+ dos: str(md.dos, 1_500),
2609
+ };
2610
+ }
2611
+ const tokens = raw.tokens;
2612
+ if (isRecord(tokens)) {
2613
+ out.tokens = {
2614
+ files: strs(tokens.files, 20, 200),
2615
+ count: Math.max(0, Math.floor(Number(tokens.count) || 0)),
2616
+ cssVars: tokens.cssVars === true,
2617
+ values: flat(tokens.values, 400),
2618
+ };
2619
+ }
2620
+ const shadcn = raw.shadcn;
2621
+ if (isRecord(shadcn)) {
2622
+ out.shadcn = {
2623
+ style: str(shadcn.style, 60),
2624
+ baseColor: str(shadcn.baseColor, 60),
2625
+ iconLibrary: str(shadcn.iconLibrary, 60),
2626
+ components: strs(shadcn.components, 80, 60),
2627
+ };
2628
+ }
2629
+ const size = JSON.stringify(out).length;
2630
+ if (size > DESIGN_FILES_MAX) {
2631
+ return `designFiles is ${size} characters serialized; at most ${DESIGN_FILES_MAX}`;
2632
+ }
2633
+ return Object.keys(out).length ? out : null;
2634
+ }
2635
+ // The installed sets a design PUT names: each must be an uploaded Iconify
2636
+ // JSON set whose prefix matches, so a typo fails here rather than as blank
2637
+ // icons on every render. The count is read off the set, not trusted.
2638
+ const MAX_ICON_SETS = 32;
2639
+ async function checkIconSets(raw) {
2640
+ if (!Array.isArray(raw))
2641
+ return "iconSets must be an array of {prefix, assetId}";
2642
+ if (raw.length > MAX_ICON_SETS)
2643
+ return `at most ${MAX_ICON_SETS} icon sets`;
2644
+ const byPrefix = new Map();
2645
+ for (const entry of raw) {
2646
+ const prefix = entry?.prefix;
2647
+ const assetId = entry?.assetId;
2648
+ if (!isIconName(prefix) || typeof assetId !== "string" || !assetId) {
2649
+ return "each icon set needs a prefix (lowercase, e.g. lucide) and an assetId";
2650
+ }
2651
+ const set = await installedIconSet(assetId);
2652
+ if (!set)
2653
+ return `icon set ${prefix}: asset ${assetId} is not an Iconify JSON set`;
2654
+ if (set.prefix !== prefix) {
2655
+ return `icon set ${prefix}: asset ${assetId} holds the ${set.prefix} set`;
2656
+ }
2657
+ byPrefix.set(prefix, { prefix, assetId, count: iconCount(set) });
2658
+ }
2659
+ return [...byPrefix.values()];
2660
+ }
2661
+ // --- push and webhooks ---
2662
+ app.get("/api/push/vapid", async (c) => c.json({ publicKey: (await vapidKeys(store)).publicKey }));
2663
+ app.post("/api/push/subscribe", async (c) => {
2664
+ const body = await c.req.json().catch(() => null);
2665
+ const subscription = body?.subscription ?? body;
2666
+ if (!isPushSubscription(subscription)) {
2667
+ return c.json({ error: "invalid push subscription" }, 400);
2668
+ }
2669
+ await addSubscription(store, subscription);
2670
+ return c.body(null, 204);
2671
+ });
2672
+ app.get("/api/hooks", async (c) => c.json(await listHooks(store)));
2673
+ app.post("/api/hooks", async (c) => {
2674
+ const body = await c.req.json().catch(() => null);
2675
+ const url = typeof body?.url === "string" ? body.url : "";
2676
+ if (!/^https?:\/\//.test(url))
2677
+ return c.json({ error: 'body must include an http(s) "url"' }, 400);
2678
+ const events = (Array.isArray(body.events) ? body.events : []).filter((e) => e === "ask" || e === "publish" || e === "decision");
2679
+ if (events.length === 0) {
2680
+ return c.json({ error: '"events" must list "ask", "publish" and/or "decision"' }, 400);
2681
+ }
2682
+ const hook = { id: newId(), url, events };
2683
+ await addHook(store, hook);
2684
+ return c.json({ id: hook.id }, 201);
2685
+ });
2686
+ app.delete("/api/hooks/:id", async (c) => {
2687
+ if (!(await removeHook(store, c.req.param("id")))) {
2688
+ return c.json({ error: "hook not found" }, 404);
2689
+ }
2690
+ return c.json({ ok: true });
2691
+ });
2692
+ // --- rendering ---
2693
+ // Serves one surface of a post as a themed, sandboxed document. The viewer
2694
+ // points an iframe here for every surface kind that becomes HTML — html surfaces
2695
+ // (author markup) and the rich kinds (markdown/code/diff/terminal rendered
2696
+ // server-side; mermaid as a self-rendering CDN doc). Image/json surfaces
2697
+ // are data the viewer renders natively (text nodes / <img> / JSX), so they
2698
+ // never reach here.
2699
+ // Everything a rendered document depends on. The resolved version makes a
2700
+ // version's content immutable, and `origin` is in the key because it is baked
2701
+ // into the document (CSP, <base>, asset URLs) — a pre-warm triggered by an
2702
+ // agent publishing over one origin must never be served to a viewer on another.
2703
+ // `knobs` is the canonical (validated, path-sorted) JSON of `?k=`, so two
2704
+ // spellings of the same values share an entry.
2705
+ const renderKey = (o) => `${o.postId}:${o.idx}:${o.version}:${o.mode ?? "os"}:${o.origin}:${o.knobs ?? ""}`;
2706
+ // The document itself. Shared by the GET below and the publish-time pre-warm,
2707
+ // so both produce byte-identical output for one key.
2708
+ async function buildSurfaceDoc(args) {
2709
+ const { surface, mode, origin } = args;
2710
+ const theme = themeById(DEFAULT_THEME_ID);
2711
+ const themeId = theme.id;
2712
+ if (surface.kind === "html") {
2713
+ // A page mock composes published components by reference; the tags
2714
+ // are expanded here, server-side, so the whole page is still ONE
2715
+ // sandboxed document rather than nested frames.
2716
+ const html = args.mock?.kind === "page"
2717
+ ? await expandPageHtml(args.mock.project, args.post, surface.html)
2718
+ : surface.html;
2719
+ return renderHtmlPage({
2720
+ title: args.title,
2721
+ html,
2722
+ origin,
2723
+ theme,
2724
+ mode,
2725
+ kits: surface.kits,
2726
+ design: args.design,
2727
+ version: args.version,
2728
+ knobs: args.knobs,
2729
+ resolveIcon: mayHaveIcons(html) ? (await iconResolverFor(args.design)).resolve : undefined,
2730
+ });
2731
+ }
2732
+ if (surface.kind === "mermaid") {
2733
+ return renderMermaidPage({ mermaid: surface.mermaid, origin, theme, mode });
2734
+ }
2735
+ // Load the rich renderers on first use, not at module load. richRender.ts
2736
+ // pulls in shiki, @pierre/diffs, markdown-it and ansi_up — measured at ~48 MB
2737
+ // of RSS and ~240 ms of import time (`npm run bench:all`, process suite), which
2738
+ // every server paid at boot whether or not it ever rendered a rich surface.
2739
+ // Deferring it past the html and mermaid branches above means an html-only
2740
+ // workspace never loads any of it.
2741
+ //
2742
+ // The runtime's module cache makes every later call cheap, so there's no memo
2743
+ // here to keep in sync. On the Worker the module is already inside the
2744
+ // deployed bundle — the import defers evaluating it, not fetching it — so this
2745
+ // needs no network at runtime. test/workerIntegration covers that on real
2746
+ // workerd, because a dynamic import resolving differently there is exactly the
2747
+ // way this optimization could break in production and nowhere else.
2748
+ const { renderCode, renderDiff, renderMarkdown, renderTerminal } = await import("./richRender.js");
2749
+ const rendered = surface.kind === "markdown"
2750
+ ? await renderMarkdown(surface, { theme: themeId, mode })
2751
+ : surface.kind === "code"
2752
+ ? await renderCode(surface, { theme: themeId, mode })
2753
+ : surface.kind === "terminal"
2754
+ ? renderTerminal(surface)
2755
+ : await renderDiff(surface, { theme: themeId, mode }).catch((e) => ({
2756
+ body: `<div class="rich-error">Couldn’t render diff — ${escapeHtml(e instanceof Error ? e.message : "render error")}</div>`,
2757
+ css: `.rich-error{color:var(--danger);font:13px/1.5 ui-monospace,monospace;padding:8px 12px;}`,
2758
+ }));
2759
+ return renderSandboxedPart({ body: rendered.body, css: rendered.css, origin, theme, mode });
2760
+ }
2761
+ // Render the current version of a post into the cache, off the write path.
2762
+ // The first thing the viewer does after a publish is load an iframe per
2763
+ // surface; warming both schemes of the workspace theme turns that cold render
2764
+ // (up to a second for a large code surface) into a cache hit. Detached on
2765
+ // purpose: a render failure must never fail — or delay — the write. (The DO
2766
+ // stays alive for the duration; there is no ExecutionContext to hand this to.)
2767
+ const PREWARM_SURFACE_LIMIT = 4;
2768
+ function warmPost(post, mock) {
2769
+ const origin = lastOrigin;
2770
+ if (!origin)
2771
+ return;
2772
+ void (async () => {
2773
+ const design = await designFor(mock.project);
2774
+ let warmed = 0;
2775
+ for (const [idx, surface] of post.surfaces.entries()) {
2776
+ if (warmed >= PREWARM_SURFACE_LIMIT)
2777
+ break;
2778
+ if (!isSandboxedSurfaceKind(surface.kind))
2779
+ continue;
2780
+ warmed++;
2781
+ for (const mode of ["light", "dark"]) {
2782
+ const key = renderKey({ postId: post.id, idx, version: post.version, mode, origin });
2783
+ if (renderCache.has(key))
2784
+ continue;
2785
+ await cachedRender(key, () => buildSurfaceDoc({
2786
+ post,
2787
+ mock,
2788
+ surface,
2789
+ title: post.title,
2790
+ version: post.version,
2791
+ mode,
2792
+ origin,
2793
+ design,
2794
+ }));
2795
+ }
2796
+ }
2797
+ })().catch((err) => console.warn("[mockpit] render pre-warm failed", err));
2798
+ }
2799
+ // `?k=` arrives URL-encoded; past this it is not a set of knob values.
2800
+ const MAX_KNOB_QUERY = 8 * 1024;
2801
+ const renderPostPage = async (c) => {
2802
+ const surfaceParam = c.req.query("surface") ?? "0";
2803
+ const ver = c.req.query("ver");
2804
+ const modeParam = c.req.query("mode");
2805
+ const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined;
2806
+ const kParam = c.req.query("k");
2807
+ const origin = new URL(c.req.url).origin;
2808
+ // Cache-first. A hit needs nothing from the post row, and the row is the
2809
+ // expensive part of this route (surfaces + every retained version). Only a
2810
+ // version-pinned request can take this path: without `ver` the current
2811
+ // version — and so the key — is unknown until the row is read. A `?k=`
2812
+ // request never does: its values are checked against the mock's knobs,
2813
+ // which can change under a pinned version.
2814
+ const pinned = Number(ver);
2815
+ if (Number.isInteger(pinned) && pinned > 0 && kParam === undefined) {
2816
+ const hit = renderCacheHit(renderKey({
2817
+ postId: c.req.param("id"),
2818
+ idx: Number(surfaceParam),
2819
+ version: pinned,
2820
+ mode,
2821
+ origin,
2822
+ }));
2823
+ if (hit !== undefined) {
2824
+ surfaceDocHeaders(c, true);
2825
+ return c.html(hit);
2826
+ }
2827
+ }
2828
+ const post = await store.getPost(c.req.param("id"));
2829
+ if (!post)
2830
+ return c.text("Post not found", 404);
2831
+ const mock = await store.getMock(post.mock);
2832
+ let title = post.title;
2833
+ let surfaces = post.surfaces;
2834
+ let version = post.version;
2835
+ if (ver && Number(ver) !== post.version) {
2836
+ const old = post.history.find((h) => h.version === Number(ver));
2837
+ if (!old)
2838
+ return c.text(`Version ${ver} not available`, 404);
2839
+ title = old.title;
2840
+ surfaces = old.surfaces;
2841
+ version = old.version;
2842
+ }
2843
+ const idx = Number(surfaceParam ?? 0);
2844
+ const surface = surfaces[idx];
2845
+ // Only the kinds that become HTML are served here. Image/json render
2846
+ // natively in the viewer and must not be reachable as a document.
2847
+ if (!surface || !isSandboxedSurfaceKind(surface.kind)) {
2848
+ return c.text("No renderable surface at that index", 404);
2849
+ }
2850
+ // Knob values are agent/user data: only values the mock (or this variant's
2851
+ // per-part overrides) declares, within their schema, ever reach the document.
2852
+ let knobs;
2853
+ let knobsKey;
2854
+ if (kParam !== undefined) {
2855
+ const bad = (error) => {
2856
+ surfaceDocHeaders(c, false);
2857
+ return c.text(error, 400);
2858
+ };
2859
+ if (kParam.length > MAX_KNOB_QUERY)
2860
+ return bad("k is too long");
2861
+ let raw;
2862
+ try {
2863
+ raw = JSON.parse(kParam);
2864
+ }
2865
+ catch {
2866
+ return bad("k must be URL-encoded JSON of {path: value}");
2867
+ }
2868
+ const checked = checkKnobValues(raw, mock ?? { knobs: {} }, [post], "k");
2869
+ if (!checked.ok)
2870
+ return bad(checked.error);
2871
+ const paths = Object.keys(checked.value).sort();
2872
+ if (paths.length > 0 && surface.kind === "html") {
2873
+ knobs = Object.fromEntries(paths.map((p) => [p, checked.value[p]]));
2874
+ knobsKey = JSON.stringify(knobs);
2875
+ }
2876
+ }
2877
+ // Every input is in the URL (the palette is fixed, the mode is a query
2878
+ // param), so a version-pinned document never changes.
2879
+ surfaceDocHeaders(c, ver != null);
2880
+ // Cache the finished document under the same key the pre-warm uses; the
2881
+ // resolved `version` makes it immutable, so a hit is always correct.
2882
+ // A page's slots are pinned at publish, so they are a function of
2883
+ // (id, version) too and need no separate key component.
2884
+ const cacheKey = renderKey({ postId: post.id, idx, version, mode, origin, knobs: knobsKey });
2885
+ const design = mock ? await designFor(mock.project) : null;
2886
+ const doc = await cachedRender(cacheKey, async () => buildSurfaceDoc({ post, mock, surface, title, version, mode, origin, design, knobs }));
2887
+ return c.html(doc);
2888
+ };
2889
+ app.get("/s/:id", renderPostPage);
2890
+ // --- assets (agent-uploaded images and files) ---
2891
+ // Accepts raw bytes (the asset's own Content-Type, metadata via query) or a
2892
+ // JSON envelope { data: base64, contentType, ... } — so curl --data-binary
2893
+ // and a JSON client both work, and MCP can ride base64. The body is read once
2894
+ // and only treated as an envelope when it is application/json carrying a
2895
+ // base64 `data` string; a raw JSON asset (no top-level `data`) stays raw.
2896
+ app.post("/api/assets", limitAssetBody, async (c) => {
2897
+ const mime = (c.req.header("content-type") ?? "").split(";")[0].trim().toLowerCase();
2898
+ // limitAssetBody has already bounded the body to MAX_ASSET_BYTES, so this
2899
+ // read is safe. The post-decode cap in uploadAsset still applies (a base64
2900
+ // envelope decodes to ~3/4), enforcing the true asset limit on the bytes.
2901
+ const buf = new Uint8Array(await c.req.arrayBuffer());
2902
+ let envelope = null;
2903
+ if (mime === "application/json") {
2904
+ try {
2905
+ const j = JSON.parse(new TextDecoder().decode(buf));
2906
+ if (j && typeof j.data === "string")
2907
+ envelope = j;
2908
+ }
2909
+ catch {
2910
+ // not an envelope — fall through to the raw path
2911
+ }
2912
+ }
2913
+ const kindQ = c.req.query("kind");
2914
+ let body;
2915
+ try {
2916
+ body = envelope
2917
+ ? {
2918
+ data: decodeBase64(envelope.data),
2919
+ contentType: typeof envelope.contentType === "string"
2920
+ ? envelope.contentType
2921
+ : "application/octet-stream",
2922
+ filename: typeof envelope.filename === "string" ? envelope.filename : undefined,
2923
+ kind: isAssetKind(envelope.kind) ? envelope.kind : undefined,
2924
+ session: typeof envelope.session === "string" ? envelope.session : undefined,
2925
+ agent: typeof envelope.agent === "string" ? envelope.agent : undefined,
2926
+ }
2927
+ : {
2928
+ data: buf,
2929
+ contentType: mime || "application/octet-stream",
2930
+ filename: c.req.query("filename"),
2931
+ kind: isAssetKind(kindQ) ? kindQ : undefined,
2932
+ session: c.req.query("session"),
2933
+ agent: c.req.query("agent"),
2934
+ };
2935
+ }
2936
+ catch (err) {
2937
+ return c.json({ error: err instanceof Error ? err.message : "invalid upload" }, 400);
2938
+ }
2939
+ const result = await uploadAsset(body);
2940
+ if ("error" in result)
2941
+ return c.json({ error: result.error }, result.status);
2942
+ const origin = new URL(c.req.url).origin;
2943
+ const publicBasePath = requestBasePath(c.req.raw);
2944
+ const assetPath = encodeURIComponent(result.asset.id);
2945
+ return c.json({ ...result.asset, url: `${origin}${publicBasePath}/a/${assetPath}` }, 201);
2946
+ });
2947
+ app.get("/a/:id", async (c) => {
2948
+ const id = c.req.param("id");
2949
+ let asset = await store.getAsset(id);
2950
+ // Optimistic uploads: an agent can derive an asset's URL from its content
2951
+ // hash and publish a surface referencing it before (or while) the bytes are
2952
+ // uploaded. Rather than 404 in that window, briefly wait for the bytes —
2953
+ // but only when a live surface actually points at this id, so unknown ids
2954
+ // still fail fast.
2955
+ if (!asset && (await store.isAssetReferenced(id))) {
2956
+ for (let i = 0; i < 20 && !asset; i++) {
2957
+ await new Promise((resolve) => setTimeout(resolve, 150));
2958
+ asset = await store.getAsset(id);
2959
+ }
2960
+ }
2961
+ if (!asset)
2962
+ return c.text("Asset not found", 404);
2963
+ await store.touchAsset(asset.id);
2964
+ const { contentType, disposition } = assetServeHeaders(asset);
2965
+ c.header("Content-Type", contentType);
2966
+ c.header("Content-Disposition", disposition);
2967
+ c.header("X-Content-Type-Options", "nosniff");
2968
+ // Short revalidating cache (not immutable) so touch-on-serve keeps firing
2969
+ // and the LRU clock reflects real views; asset ids are unique anyway.
2970
+ c.header("Cache-Control", "private, max-age=60");
2971
+ return c.body(asset.data);
2972
+ });
2973
+ // --- live feed ---
2974
+ app.get("/api/events", async (c) => {
2975
+ const sessionId = c.req.query("session");
2976
+ const mockId = c.req.query("mock");
2977
+ if (isUnauthenticatedSessionRead(c)) {
2978
+ if (!sessionId && !mockId)
2979
+ return c.json({ error: "session or mock required" }, 401);
2980
+ if (sessionId && !(await store.getSession(sessionId))) {
2981
+ return c.json({ error: "session not found" }, 404);
2982
+ }
2983
+ if (mockId && !(await store.getMock(mockId))) {
2984
+ return c.json({ error: "mock not found" }, 404);
2985
+ }
2986
+ }
2987
+ if (!acquireHold())
2988
+ return c.json({ error: "too many concurrent connections" }, 503);
2989
+ viewerClients++;
2990
+ let attached = true;
2991
+ const releaseHold = makeRelease();
2992
+ const release = () => {
2993
+ if (attached)
2994
+ viewerClients--;
2995
+ attached = false;
2996
+ releaseHold();
2997
+ };
2998
+ // Safety net: if the client disconnects before the stream callback opens,
2999
+ // the request abort still releases the slot. close() below is guarded so a
3000
+ // later abort firing release again is a no-op.
3001
+ c.req.raw.signal.addEventListener("abort", release, { once: true });
3002
+ const eventSessionId = (event) => {
3003
+ if ("sessionId" in event)
3004
+ return event.sessionId;
3005
+ if (event.type === "session-created" ||
3006
+ event.type === "session-updated" ||
3007
+ event.type === "session-deleted") {
3008
+ return event.id;
3009
+ }
3010
+ return undefined;
3011
+ };
3012
+ const eventMockId = (event) => {
3013
+ if ("mockId" in event)
3014
+ return event.mockId;
3015
+ if (event.type === "mock-created" ||
3016
+ event.type === "mock-updated" ||
3017
+ event.type === "mock-deleted") {
3018
+ return event.id;
3019
+ }
3020
+ return undefined;
3021
+ };
3022
+ return streamSSE(c, async (stream) => {
3023
+ const queue = [];
3024
+ let wake = null;
3025
+ const unsubscribe = bus.subscribe((event) => {
3026
+ if (sessionId && eventSessionId(event) !== sessionId)
3027
+ return;
3028
+ if (mockId && eventMockId(event) !== mockId)
3029
+ return;
3030
+ queue.push(event);
3031
+ wake?.();
3032
+ });
3033
+ let open = true;
3034
+ let closed = false;
3035
+ const close = () => {
3036
+ if (closed)
3037
+ return;
3038
+ closed = true;
3039
+ open = false;
3040
+ unsubscribe();
3041
+ wake?.();
3042
+ release();
3043
+ };
3044
+ stream.onAbort(close);
3045
+ c.req.raw.signal.addEventListener("abort", close, { once: true });
3046
+ try {
3047
+ await stream.writeSSE({ event: "hello", data: "{}" });
3048
+ while (open) {
3049
+ while (queue.length > 0) {
3050
+ await stream.writeSSE({ data: JSON.stringify(queue.shift()) });
3051
+ }
3052
+ let pingTimer = null;
3053
+ await Promise.race([
3054
+ new Promise((resolve) => {
3055
+ wake = resolve;
3056
+ }),
3057
+ new Promise((resolve) => {
3058
+ pingTimer = setTimeout(resolve, 15000);
3059
+ }),
3060
+ ]);
3061
+ wake = null;
3062
+ if (pingTimer)
3063
+ clearTimeout(pingTimer);
3064
+ if (open && queue.length === 0) {
3065
+ await stream.writeSSE({ event: "ping", data: "{}" });
3066
+ }
3067
+ }
3068
+ }
3069
+ finally {
3070
+ close();
3071
+ }
3072
+ });
3073
+ });
3074
+ // --- MCP over streamable HTTP (works locally and deployed) ---
3075
+ const mcpFlows = {
3076
+ publish: (body, ctx) => publishFlow(body, ctx),
3077
+ list: (query) => listMocksFlow(query),
3078
+ get: (ref, query) => getMockFlow(ref, query),
3079
+ ask: (ref, body, ctx) => askFlow(ref, body, ctx),
3080
+ exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
3081
+ say: (ref, body) => sayFlow(ref, body),
3082
+ feedback: (query) => feedbackFlow(query),
3083
+ };
3084
+ const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
3085
+ // --- run: an agent script over the same flows (server/run.ts) ---
3086
+ const runFlow = createRunFlow({
3087
+ store,
3088
+ executor,
3089
+ limits: { ...DEFAULT_RUN_LIMITS, ...runLimits },
3090
+ flows: mcpFlows,
3091
+ guide,
3092
+ uploadAsset,
3093
+ async createSession({ agent, project }) {
3094
+ const session = await store.createSession({ agent, project: resolveProject(project) });
3095
+ bus.broadcast({ type: "session-created", id: session.id });
3096
+ return session.id;
3097
+ },
3098
+ });
3099
+ // Never the viewer's verb: a script acts as the agent whatever origin sent it.
3100
+ app.post("/api/run", async (c) => send(c, await runFlow(await jsonBody(c), flowCtx(c, false))));
3101
+ registerMcp(app, {
3102
+ store,
3103
+ basePath: requestBasePath,
3104
+ flows: mcpFlows,
3105
+ run: runFlow,
3106
+ uploadAsset,
3107
+ // Every feature works on every tier: a remote MCP agent needs the project's
3108
+ // real palette and kit, and the topics, as much as a shell one does.
3109
+ guide,
3110
+ });
3111
+ return app;
3112
+ }