plotlink-ows 1.0.32 → 1.2.94

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 (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +10 -3
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +209 -28
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +1017 -144
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -1,8 +1,49 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
- import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, uploadPlotImage, updateStoryline } from "../lib/publish";
3
+ import { publishStoryline, publishPlot, getEthBalance, getCreationFee, estimatePublishCost, uploadCoverImage, uploadPlotImage, updateStoryline } from "../lib/publish";
4
4
  import { keccak256, toBytes } from "viem";
5
5
  import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
6
+ import path from "path";
7
+ import { STORIES_DIR } from "../lib/paths";
8
+ import { readCutsFile } from "../lib/cuts";
9
+ import { checkMarkdownReadiness } from "../lib/cartoon-readiness";
10
+ import { readStoryMeta } from "./stories";
11
+ import { sniffImageType, findStaleAssetPaths } from "../lib/clean-image-sync";
12
+ import { isValidImageAsset } from "../lib/image-asset-validate";
13
+ import { extractOgTitle, leadingTitleSegment } from "../lib/public-title";
14
+ import { canonicalizeGenre, GENRES } from "../../lib/genres";
15
+
16
+ /**
17
+ * Resolve a request's genre to a canonical PlotLink value (#412). Returns the
18
+ * canonical string for a valid/aliased genre, `undefined` for an absent/blank
19
+ * genre (no metadata change), or an `{ error }` for a non-empty genre that can't
20
+ * be mapped — so the route fails locally with a clear message instead of letting
21
+ * PlotLink reject it and leave the public story UNCATEGORIZED.
22
+ */
23
+ function resolveGenre(input: string | undefined): { genre?: string } | { error: string } {
24
+ if (!input || !input.trim()) return { genre: undefined };
25
+ const canonical = canonicalizeGenre(input);
26
+ if (!canonical) {
27
+ return { error: `Invalid genre "${input}". Use one of: ${GENRES.join(", ")}.` };
28
+ }
29
+ return { genre: canonical };
30
+ }
31
+
32
+ /**
33
+ * Validate that an uploaded image's actual magic bytes match its claimed
34
+ * WebP/JPEG MIME type, so a renamed PNG/text file labeled image/webp cannot be
35
+ * forwarded to the plotlink backend. Mirrors the byte check the cartoon
36
+ * clean-image upload uses (#266). Returns a user-facing error string, or null
37
+ * when the bytes are valid.
38
+ */
39
+ async function imageBytesError(file: File): Promise<string | null> {
40
+ const expected = file.type === "image/webp" ? "webp" : "jpeg";
41
+ const bytes = new Uint8Array(await file.arrayBuffer());
42
+ if (sniffImageType(bytes) !== expected) {
43
+ return "File content is not a valid WebP/JPEG image (bytes do not match the image type)";
44
+ }
45
+ return null;
46
+ }
6
47
 
7
48
  const publish = new Hono();
8
49
 
@@ -21,41 +62,61 @@ publish.get("/preflight", async (c) => {
21
62
  return c.json({ ready: false, error: "No EVM address on wallet" });
22
63
  }
23
64
 
24
- // Check ETH balance against real estimated cost
25
65
  const balance = await getEthBalance(address);
66
+
67
+ // The MCV2 Bond creation fee is a plain contract read and is the only hard
68
+ // on-chain cost we can always count on. Read it independently of gas
69
+ // estimation: a flaky gas estimate must not masquerade as an unreadable-chain
70
+ // failure. If the fee itself cannot be read, the RPC/contract config is
71
+ // genuinely broken — that is a real blocker.
72
+ let creationFee: bigint;
73
+ try {
74
+ creationFee = await getCreationFee();
75
+ } catch {
76
+ return c.json({
77
+ ready: false,
78
+ address,
79
+ ethBalance: balance.toString(),
80
+ error: "Could not read creation fee — check RPC and contract config",
81
+ });
82
+ }
83
+
84
+ // Gas estimation is best-effort. PlotLink owns Filebase/IPFS server-side and
85
+ // OWS uploads images/content through the PlotLink API, so there is NO local
86
+ // Filebase env requirement to gate on (#287). And gas estimation simulates
87
+ // createStoryline with dummy content the contract can reject — which is why
88
+ // the real #211 pilot publish succeeded while this estimate failed. So a
89
+ // failed estimate is a warning, not an absolute publish blocker.
26
90
  let totalCost: bigint | null = null;
27
- let creationFee = BigInt(0);
28
- let estimationFailed = false;
91
+ let estimateWarning: string | null = null;
29
92
  try {
30
93
  const dummyCid = "QmDummy";
31
94
  const dummyHash = keccak256(toBytes("estimation"));
32
95
  const estimate = await estimatePublishCost(address, "Test", dummyCid, dummyHash);
33
96
  totalCost = estimate.totalCost;
34
- creationFee = estimate.creationFee;
35
97
  } catch {
36
- estimationFailed = true;
98
+ estimateWarning =
99
+ "Could not estimate gas; using the creation fee as the minimum required balance — actual publish may still succeed";
37
100
  }
38
- // Fail closed: if estimation fails, block publishing
39
- const requiredBalance = totalCost ?? BigInt(0);
40
- const hasEnoughEth = !estimationFailed && totalCost !== null && balance >= requiredBalance;
41
101
 
42
- // Check Filebase config
43
- const hasFilebase = !!(process.env.FILEBASE_ACCESS_KEY && process.env.FILEBASE_SECRET_KEY);
102
+ // Require enough ETH for at least the creation fee; when a full gas estimate
103
+ // is available, require that instead. Never block solely on a missing
104
+ // estimate — but a genuinely insufficient balance is still a real blocker.
105
+ const requiredBalance = totalCost ?? creationFee;
106
+ const hasEnoughEth = balance >= requiredBalance;
44
107
 
45
108
  return c.json({
46
- ready: hasEnoughEth && hasFilebase,
109
+ ready: hasEnoughEth,
47
110
  address,
48
111
  ethBalance: balance.toString(),
49
112
  creationFee: creationFee.toString(),
50
113
  requiredBalance: requiredBalance.toString(),
51
114
  hasEnoughEth,
52
- hasFilebase,
53
- estimationFailed,
54
- error: estimationFailed
55
- ? "Could not estimate publish cost check RPC and contract config"
56
- : !hasEnoughEth
57
- ? `Insufficient ETH. Need ~${(Number(requiredBalance) / 1e18).toFixed(6)} ETH (creation fee + gas)`
58
- : !hasFilebase ? "Filebase not configured" : null,
115
+ estimationFailed: totalCost === null,
116
+ estimateWarning,
117
+ error: !hasEnoughEth
118
+ ? `Insufficient ETH. Need at least ~${(Number(requiredBalance) / 1e18).toFixed(6)} ETH (creation fee${totalCost !== null ? " + gas" : ""})`
119
+ : null,
59
120
  });
60
121
  } catch (err: unknown) {
61
122
  const message = err instanceof Error ? err.message : "Preflight check failed";
@@ -63,6 +124,58 @@ publish.get("/preflight", async (c) => {
63
124
  }
64
125
  });
65
126
 
127
+ /**
128
+ * GET /api/publish/public-title — read the INDEXED public title from PlotLink
129
+ * after a publish (#379). There is no public JSON read endpoint, so this fetches
130
+ * the rendered public page server-side (no CORS) and extracts its og:title:
131
+ * - genesis/storyline (`/story/<id>`) → { storylineTitle }
132
+ * - plot (`/story/<id>/<plotIndex>`) → { plotTitle } (leading title segment)
133
+ * `fetched:false` when the page is unreachable / has no title, so the caller
134
+ * treats it as inconclusive rather than a false pass. The client then runs the
135
+ * pure title verifier on the result.
136
+ */
137
+ publish.get("/public-title", async (c) => {
138
+ const storylineId = c.req.query("storylineId");
139
+ const plotIndex = c.req.query("plotIndex");
140
+ if (!storylineId || !/^\d+$/.test(storylineId)) {
141
+ return c.json({ ok: false, error: "Valid storylineId required" }, 400);
142
+ }
143
+ const isPlot = plotIndex != null && plotIndex !== "" && /^\d+$/.test(plotIndex);
144
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
145
+ const url = isPlot
146
+ ? `${PLOTLINK_URL}/story/${storylineId}/${plotIndex}`
147
+ : `${PLOTLINK_URL}/story/${storylineId}`;
148
+ const storylineUrl = `${PLOTLINK_URL}/story/${storylineId}`;
149
+
150
+ try {
151
+ if (!isPlot) {
152
+ const res = await fetch(url);
153
+ if (!res.ok) return c.json({ ok: true, fetched: false });
154
+ const og = extractOgTitle(await res.text());
155
+ if (!og) return c.json({ ok: true, fetched: false });
156
+ return c.json({ ok: true, fetched: true, storylineTitle: og });
157
+ }
158
+
159
+ const plotRes = await fetch(url);
160
+ if (!plotRes.ok) return c.json({ ok: true, fetched: false });
161
+ const plotOg = extractOgTitle(await plotRes.text());
162
+ if (!plotOg) return c.json({ ok: true, fetched: false });
163
+ let storylineOg: string | null = null;
164
+ try {
165
+ const storylineRes = await fetch(storylineUrl);
166
+ storylineOg = storylineRes.ok ? extractOgTitle(await storylineRes.text()) : null;
167
+ } catch {
168
+ // Best-effort read only. If the storyline page fetch rejects, keep the
169
+ // successful plot-page title and fall back to last-segment stripping.
170
+ storylineOg = null;
171
+ }
172
+ return c.json({ ok: true, fetched: true, plotTitle: leadingTitleSegment(plotOg, storylineOg) });
173
+ } catch {
174
+ // Network/parse failure → inconclusive; never block on a flaky read.
175
+ return c.json({ ok: true, fetched: false });
176
+ }
177
+ });
178
+
66
179
  /** POST /api/publish/file — publish a story file on-chain (streams progress) */
67
180
  publish.post("/file", async (c) => {
68
181
  const body = await c.req.json<{
@@ -74,12 +187,22 @@ publish.post("/file", async (c) => {
74
187
  language?: string;
75
188
  isNsfw?: boolean;
76
189
  storylineId?: number;
190
+ contentType?: string;
77
191
  }>();
78
192
 
79
193
  if (!body.title || !body.content) {
80
194
  return c.json({ error: "title and content required" }, 400);
81
195
  }
82
196
 
197
+ // Canonicalize the genre up-front (#412) so it's the canonical PlotLink value in
198
+ // the content metadata, and a non-mappable genre fails here with a clear message
199
+ // instead of after the on-chain publish.
200
+ const genreResult = resolveGenre(body.genre);
201
+ if ("error" in genreResult) {
202
+ return c.json({ error: genreResult.error }, 400);
203
+ }
204
+ const canonicalGenre = genreResult.genre;
205
+
83
206
  // Enforce character limits
84
207
  const isGenesis = body.fileName === "genesis.md";
85
208
  const isPlot = /^plot-\d+\.md$/.test(body.fileName);
@@ -90,6 +213,52 @@ publish.post("/file", async (c) => {
90
213
  }, 400);
91
214
  }
92
215
 
216
+ // Cartoon plot readiness — block invalid/incomplete publish markdown.
217
+ // Derive cartoon status from server-side .story.json metadata (NOT the
218
+ // request body) so a direct API caller cannot bypass by omitting/faking
219
+ // contentType.
220
+ if (isPlot) {
221
+ const storyDir = path.join(STORIES_DIR, body.storyName);
222
+ const isCartoon = readStoryMeta(storyDir).contentType === "cartoon";
223
+ if (isCartoon) {
224
+ const plotFile = body.fileName.replace(/\.md$/, "");
225
+ let cutsFile;
226
+ try {
227
+ cutsFile = readCutsFile(storyDir, plotFile);
228
+ } catch (err) {
229
+ return c.json({ error: `Cannot publish: ${(err as Error).message}` }, 400);
230
+ }
231
+ if (!cutsFile) {
232
+ return c.json({ error: `Cannot publish: ${plotFile}.cuts.json not found. Generate cuts and upload final images first.` }, 400);
233
+ }
234
+
235
+ // Block on stale recorded asset paths — a cut whose cleanImagePath /
236
+ // finalImagePath points to a missing/invalid local file is not actually
237
+ // ready, and the generic markdown issues would obscure why (#302). Skip
238
+ // already-uploaded cuts: their content is on IPFS (uploadedUrl), so a
239
+ // missing LOCAL asset must not block re-publish.
240
+ const stale = findStaleAssetPaths(
241
+ cutsFile.cuts,
242
+ (rel) => isValidImageAsset(storyDir, rel),
243
+ ).filter((issue) => {
244
+ const cut = cutsFile.cuts.find((ct) => ct.id === issue.cutId);
245
+ return !cut?.uploadedUrl;
246
+ });
247
+ if (stale.length > 0) {
248
+ const messages = stale.map((s) => s.message);
249
+ return c.json(
250
+ { error: `Cartoon plot not ready to publish: ${messages.join("; ")}`, issues: messages },
251
+ 400,
252
+ );
253
+ }
254
+
255
+ const { ready, issues } = checkMarkdownReadiness(body.content, cutsFile.cuts);
256
+ if (!ready) {
257
+ return c.json({ error: `Cartoon plot not ready to publish: ${issues.join("; ")}`, issues }, 400);
258
+ }
259
+ }
260
+ }
261
+
93
262
  // Get wallet
94
263
  let wallets;
95
264
  try {
@@ -116,7 +285,7 @@ publish.post("/file", async (c) => {
116
285
  body.storylineId,
117
286
  body.title,
118
287
  body.content,
119
- body.genre,
288
+ canonicalGenre,
120
289
  async (progress) => {
121
290
  await stream.writeSSE({ data: JSON.stringify(progress) });
122
291
  },
@@ -128,12 +297,13 @@ publish.post("/file", async (c) => {
128
297
  wallet.name,
129
298
  body.title,
130
299
  body.content,
131
- body.genre,
300
+ canonicalGenre,
132
301
  async (progress) => {
133
302
  await stream.writeSSE({ data: JSON.stringify(progress) });
134
303
  },
135
304
  body.language,
136
305
  body.isNsfw,
306
+ body.contentType,
137
307
  );
138
308
  }
139
309
 
@@ -212,9 +382,9 @@ publish.post("/upload-cover", async (c) => {
212
382
  return c.json({ error: "No image file provided" }, 400);
213
383
  }
214
384
 
215
- // Validate file size (500KB max)
216
- if (file.size > 500 * 1024) {
217
- return c.json({ error: "Image exceeds 500KB limit" }, 400);
385
+ // Validate file size (1MB max)
386
+ if (file.size > 1024 * 1024) {
387
+ return c.json({ error: "Image exceeds 1MB limit" }, 400);
218
388
  }
219
389
 
220
390
  // Validate file type — only WebP and JPEG accepted by the plotlink server
@@ -223,6 +393,9 @@ publish.post("/upload-cover", async (c) => {
223
393
  return c.json({ error: "Only WebP and JPEG images are accepted" }, 400);
224
394
  }
225
395
 
396
+ const bytesError = await imageBytesError(file);
397
+ if (bytesError) return c.json({ error: bytesError }, 400);
398
+
226
399
  const cid = await uploadCoverImage(wallet.name, address as `0x${string}`, file);
227
400
  return c.json({ cid });
228
401
  } catch (err: unknown) {
@@ -247,9 +420,9 @@ publish.post("/upload-plot-image", async (c) => {
247
420
  return c.json({ error: "No image file provided" }, 400);
248
421
  }
249
422
 
250
- // Validate file size (500KB max)
251
- if (file.size > 500 * 1024) {
252
- return c.json({ error: "Image exceeds 500KB limit" }, 400);
423
+ // Validate file size (1MB max)
424
+ if (file.size > 1024 * 1024) {
425
+ return c.json({ error: "Image exceeds 1MB limit" }, 400);
253
426
  }
254
427
 
255
428
  // Validate file type — only WebP and JPEG accepted by the plotlink server
@@ -288,13 +461,21 @@ publish.post("/update-storyline", async (c) => {
288
461
  return c.json({ error: "storylineId required" }, 400);
289
462
  }
290
463
 
464
+ // Canonicalize the genre before the signed on-chain metadata update so a
465
+ // natural label (e.g. "Sci-Fi") becomes "Science Fiction" rather than being
466
+ // rejected by PlotLink and leaving the public story UNCATEGORIZED (#412).
467
+ const genreResult = resolveGenre(body.genre);
468
+ if ("error" in genreResult) {
469
+ return c.json({ error: genreResult.error }, 400);
470
+ }
471
+
291
472
  await updateStoryline(
292
473
  wallet.name,
293
474
  address as `0x${string}`,
294
475
  body.storylineId,
295
476
  {
296
477
  coverCid: body.coverCid,
297
- genre: body.genre,
478
+ genre: genreResult.genre,
298
479
  language: body.language,
299
480
  isNsfw: body.isNsfw,
300
481
  },