plotlink-ows 1.0.33 → 1.2.95

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 (152) hide show
  1. package/README.md +4 -0
  2. package/app/lib/active-wallet.ts +260 -0
  3. package/app/lib/agent-command.ts +85 -0
  4. package/app/lib/agent-readiness.ts +133 -0
  5. package/app/lib/apply-schema.ts +55 -0
  6. package/app/lib/bubble-text.ts +160 -0
  7. package/app/lib/cartoon-coach.ts +198 -0
  8. package/app/lib/cartoon-markdown.ts +83 -0
  9. package/app/lib/cartoon-prompt.ts +122 -0
  10. package/app/lib/cartoon-readiness.ts +813 -0
  11. package/app/lib/clean-image-sync.ts +245 -0
  12. package/app/lib/codex-images.ts +152 -0
  13. package/app/lib/cut-asset-diagnostics.ts +120 -0
  14. package/app/lib/cuts.ts +302 -0
  15. package/app/lib/fonts.ts +109 -0
  16. package/app/lib/generate-claude-md.ts +8 -1
  17. package/app/lib/generate-story-instructions.ts +731 -0
  18. package/app/lib/image-asset-validate.ts +123 -0
  19. package/app/lib/lettering-status.ts +133 -0
  20. package/app/lib/overlays.ts +637 -0
  21. package/app/lib/paths.ts +10 -0
  22. package/app/lib/public-title.ts +65 -0
  23. package/app/lib/publish.ts +16 -2
  24. package/app/lib/story-progress.ts +242 -0
  25. package/app/lib/terminal-protocol.ts +16 -0
  26. package/app/lib/terminal-redact.ts +50 -0
  27. package/app/prisma/schema.sql +25 -0
  28. package/app/routes/agent.ts +42 -0
  29. package/app/routes/codex-images.ts +67 -0
  30. package/app/routes/dashboard.ts +6 -4
  31. package/app/routes/publish.ts +259 -45
  32. package/app/routes/settings.ts +92 -37
  33. package/app/routes/stories.ts +961 -5
  34. package/app/routes/terminal.ts +383 -31
  35. package/app/routes/wallet.ts +58 -30
  36. package/app/server.ts +47 -12
  37. package/app/vite.config.ts +6 -0
  38. package/app/web/components/CartoonNextAction.tsx +145 -0
  39. package/app/web/components/CartoonPreview.tsx +267 -0
  40. package/app/web/components/CartoonPublishPage.tsx +407 -0
  41. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  42. package/app/web/components/CartoonStepGuide.tsx +90 -0
  43. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  44. package/app/web/components/CodexImportPicker.tsx +230 -0
  45. package/app/web/components/CutListPanel.tsx +1337 -0
  46. package/app/web/components/Dashboard.tsx +15 -6
  47. package/app/web/components/EpisodesPage.tsx +80 -0
  48. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  49. package/app/web/components/Layout.tsx +7 -4
  50. package/app/web/components/LetteringEditor.tsx +1182 -0
  51. package/app/web/components/PreviewPanel.tsx +952 -78
  52. package/app/web/components/Settings.tsx +63 -0
  53. package/app/web/components/StoriesPage.tsx +745 -33
  54. package/app/web/components/StoryBrowser.tsx +22 -14
  55. package/app/web/components/StoryInfoPage.tsx +266 -0
  56. package/app/web/components/StoryProgressPanel.tsx +446 -0
  57. package/app/web/components/TerminalPanel.tsx +233 -11
  58. package/app/web/components/WalletCard.tsx +110 -8
  59. package/app/web/components/WorkflowCoach.tsx +156 -0
  60. package/app/web/components/asset-image.tsx +114 -0
  61. package/app/web/components/asset-test-utils.ts +44 -0
  62. package/app/web/components/export-cut.ts +320 -0
  63. package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
  64. package/app/web/dist/assets/index-CcfChGEK.css +32 -0
  65. package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
  66. package/app/web/dist/index.html +2 -2
  67. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  68. package/app/web/lib/codex-import.ts +94 -0
  69. package/app/web/lib/image-compress.ts +53 -0
  70. package/app/web/lib/import-image.ts +58 -0
  71. package/app/web/lib/publish-helpers.ts +385 -0
  72. package/app/web/lib/upload-retry.ts +130 -0
  73. package/app/web/lib/verify-public-title.ts +105 -0
  74. package/app/web/styles.css +9 -0
  75. package/bin/plotlink-ows.js +53 -16
  76. package/bin/startup-plan.cjs +58 -0
  77. package/lib/genres.ts +92 -0
  78. package/package.json +60 -20
  79. package/scripts/gen-schema-sql.mjs +49 -0
  80. package/scripts/package-hygiene.mjs +116 -0
  81. package/scripts/preflight.mjs +173 -0
  82. package/scripts/start-smoke.mjs +128 -0
  83. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  84. package/app/node_modules/.prisma/local-client/client.js +0 -5
  85. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  86. package/app/node_modules/.prisma/local-client/default.js +0 -5
  87. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  88. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  89. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  90. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  91. package/app/node_modules/.prisma/local-client/index.js +0 -207
  92. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  93. package/app/node_modules/.prisma/local-client/package.json +0 -183
  94. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  95. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  96. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  97. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  98. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  99. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  100. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  101. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  102. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  103. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  104. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  105. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  106. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  107. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  108. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  109. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  110. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  111. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  112. package/packages/cli/node_modules/commander/LICENSE +0 -22
  113. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  114. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  115. package/packages/cli/node_modules/commander/index.js +0 -24
  116. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  117. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  118. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  119. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  120. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  121. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  122. package/packages/cli/node_modules/commander/package-support.json +0 -16
  123. package/packages/cli/node_modules/commander/package.json +0 -82
  124. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  125. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  126. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  127. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  128. package/packages/cli/node_modules/resolve-from/license +0 -9
  129. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  130. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  131. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  132. package/packages/cli/node_modules/tsup/README.md +0 -75
  133. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  134. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  135. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  136. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  137. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  138. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  139. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  140. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  141. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  142. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  143. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  144. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  145. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  146. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  147. package/packages/cli/node_modules/tsup/package.json +0 -99
  148. package/packages/cli/node_modules/tsup/schema.json +0 -362
  149. package/public/screenshot-1.png +0 -0
  150. package/public/screenshot-2.png +0 -0
  151. package/public/screenshot-3.png +0 -0
  152. 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
- import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
5
+ import { resolveActiveWallet } from "../lib/active-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
 
@@ -10,52 +51,81 @@ const publish = new Hono();
10
51
  publish.get("/preflight", async (c) => {
11
52
  try {
12
53
  // Check wallet
13
- const wallets = listAgentWallets();
14
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
54
+ const resolvedWallet = await resolveActiveWallet();
55
+ const wallet = resolvedWallet.activeWallet;
15
56
  if (!wallet) {
16
- return c.json({ ready: false, error: "No OWS wallet found" });
57
+ return c.json({
58
+ ready: false,
59
+ error: resolvedWallet.error || "No OWS wallet found",
60
+ selectionRequired: resolvedWallet.selectionRequired,
61
+ wallets: resolvedWallet.wallets,
62
+ });
17
63
  }
18
64
 
19
- const address = getBaseAddress(wallet);
65
+ const address = wallet.address;
20
66
  if (!address) {
21
67
  return c.json({ ready: false, error: "No EVM address on wallet" });
22
68
  }
23
69
 
24
- // Check ETH balance against real estimated cost
25
70
  const balance = await getEthBalance(address);
71
+
72
+ // The MCV2 Bond creation fee is a plain contract read and is the only hard
73
+ // on-chain cost we can always count on. Read it independently of gas
74
+ // estimation: a flaky gas estimate must not masquerade as an unreadable-chain
75
+ // failure. If the fee itself cannot be read, the RPC/contract config is
76
+ // genuinely broken — that is a real blocker.
77
+ let creationFee: bigint;
78
+ try {
79
+ creationFee = await getCreationFee();
80
+ } catch {
81
+ return c.json({
82
+ ready: false,
83
+ address,
84
+ walletName: wallet.name,
85
+ walletId: wallet.walletId,
86
+ ethBalance: balance.toString(),
87
+ error: "Could not read creation fee — check RPC and contract config",
88
+ });
89
+ }
90
+
91
+ // Gas estimation is best-effort. PlotLink owns Filebase/IPFS server-side and
92
+ // OWS uploads images/content through the PlotLink API, so there is NO local
93
+ // Filebase env requirement to gate on (#287). And gas estimation simulates
94
+ // createStoryline with dummy content the contract can reject — which is why
95
+ // the real #211 pilot publish succeeded while this estimate failed. So a
96
+ // failed estimate is a warning, not an absolute publish blocker.
26
97
  let totalCost: bigint | null = null;
27
- let creationFee = BigInt(0);
28
- let estimationFailed = false;
98
+ let estimateWarning: string | null = null;
29
99
  try {
30
100
  const dummyCid = "QmDummy";
31
101
  const dummyHash = keccak256(toBytes("estimation"));
32
102
  const estimate = await estimatePublishCost(address, "Test", dummyCid, dummyHash);
33
103
  totalCost = estimate.totalCost;
34
- creationFee = estimate.creationFee;
35
104
  } catch {
36
- estimationFailed = true;
105
+ estimateWarning =
106
+ "Could not estimate gas; using the creation fee as the minimum required balance — actual publish may still succeed";
37
107
  }
38
- // Fail closed: if estimation fails, block publishing
39
- const requiredBalance = totalCost ?? BigInt(0);
40
- const hasEnoughEth = !estimationFailed && totalCost !== null && balance >= requiredBalance;
41
108
 
42
- // Check Filebase config
43
- const hasFilebase = !!(process.env.FILEBASE_ACCESS_KEY && process.env.FILEBASE_SECRET_KEY);
109
+ // Require enough ETH for at least the creation fee; when a full gas estimate
110
+ // is available, require that instead. Never block solely on a missing
111
+ // estimate — but a genuinely insufficient balance is still a real blocker.
112
+ const requiredBalance = totalCost ?? creationFee;
113
+ const hasEnoughEth = balance >= requiredBalance;
44
114
 
45
115
  return c.json({
46
- ready: hasEnoughEth && hasFilebase,
116
+ ready: hasEnoughEth,
47
117
  address,
118
+ walletName: wallet.name,
119
+ walletId: wallet.walletId,
48
120
  ethBalance: balance.toString(),
49
121
  creationFee: creationFee.toString(),
50
122
  requiredBalance: requiredBalance.toString(),
51
123
  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,
124
+ estimationFailed: totalCost === null,
125
+ estimateWarning,
126
+ error: !hasEnoughEth
127
+ ? `Insufficient ETH. Need at least ~${(Number(requiredBalance) / 1e18).toFixed(6)} ETH (creation fee${totalCost !== null ? " + gas" : ""})`
128
+ : null,
59
129
  });
60
130
  } catch (err: unknown) {
61
131
  const message = err instanceof Error ? err.message : "Preflight check failed";
@@ -63,6 +133,58 @@ publish.get("/preflight", async (c) => {
63
133
  }
64
134
  });
65
135
 
136
+ /**
137
+ * GET /api/publish/public-title — read the INDEXED public title from PlotLink
138
+ * after a publish (#379). There is no public JSON read endpoint, so this fetches
139
+ * the rendered public page server-side (no CORS) and extracts its og:title:
140
+ * - genesis/storyline (`/story/<id>`) → { storylineTitle }
141
+ * - plot (`/story/<id>/<plotIndex>`) → { plotTitle } (leading title segment)
142
+ * `fetched:false` when the page is unreachable / has no title, so the caller
143
+ * treats it as inconclusive rather than a false pass. The client then runs the
144
+ * pure title verifier on the result.
145
+ */
146
+ publish.get("/public-title", async (c) => {
147
+ const storylineId = c.req.query("storylineId");
148
+ const plotIndex = c.req.query("plotIndex");
149
+ if (!storylineId || !/^\d+$/.test(storylineId)) {
150
+ return c.json({ ok: false, error: "Valid storylineId required" }, 400);
151
+ }
152
+ const isPlot = plotIndex != null && plotIndex !== "" && /^\d+$/.test(plotIndex);
153
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
154
+ const url = isPlot
155
+ ? `${PLOTLINK_URL}/story/${storylineId}/${plotIndex}`
156
+ : `${PLOTLINK_URL}/story/${storylineId}`;
157
+ const storylineUrl = `${PLOTLINK_URL}/story/${storylineId}`;
158
+
159
+ try {
160
+ if (!isPlot) {
161
+ const res = await fetch(url);
162
+ if (!res.ok) return c.json({ ok: true, fetched: false });
163
+ const og = extractOgTitle(await res.text());
164
+ if (!og) return c.json({ ok: true, fetched: false });
165
+ return c.json({ ok: true, fetched: true, storylineTitle: og });
166
+ }
167
+
168
+ const plotRes = await fetch(url);
169
+ if (!plotRes.ok) return c.json({ ok: true, fetched: false });
170
+ const plotOg = extractOgTitle(await plotRes.text());
171
+ if (!plotOg) return c.json({ ok: true, fetched: false });
172
+ let storylineOg: string | null = null;
173
+ try {
174
+ const storylineRes = await fetch(storylineUrl);
175
+ storylineOg = storylineRes.ok ? extractOgTitle(await storylineRes.text()) : null;
176
+ } catch {
177
+ // Best-effort read only. If the storyline page fetch rejects, keep the
178
+ // successful plot-page title and fall back to last-segment stripping.
179
+ storylineOg = null;
180
+ }
181
+ return c.json({ ok: true, fetched: true, plotTitle: leadingTitleSegment(plotOg, storylineOg) });
182
+ } catch {
183
+ // Network/parse failure → inconclusive; never block on a flaky read.
184
+ return c.json({ ok: true, fetched: false });
185
+ }
186
+ });
187
+
66
188
  /** POST /api/publish/file — publish a story file on-chain (streams progress) */
67
189
  publish.post("/file", async (c) => {
68
190
  const body = await c.req.json<{
@@ -74,12 +196,22 @@ publish.post("/file", async (c) => {
74
196
  language?: string;
75
197
  isNsfw?: boolean;
76
198
  storylineId?: number;
199
+ contentType?: string;
77
200
  }>();
78
201
 
79
202
  if (!body.title || !body.content) {
80
203
  return c.json({ error: "title and content required" }, 400);
81
204
  }
82
205
 
206
+ // Canonicalize the genre up-front (#412) so it's the canonical PlotLink value in
207
+ // the content metadata, and a non-mappable genre fails here with a clear message
208
+ // instead of after the on-chain publish.
209
+ const genreResult = resolveGenre(body.genre);
210
+ if ("error" in genreResult) {
211
+ return c.json({ error: genreResult.error }, 400);
212
+ }
213
+ const canonicalGenre = genreResult.genre;
214
+
83
215
  // Enforce character limits
84
216
  const isGenesis = body.fileName === "genesis.md";
85
217
  const isPlot = /^plot-\d+\.md$/.test(body.fileName);
@@ -90,16 +222,68 @@ publish.post("/file", async (c) => {
90
222
  }, 400);
91
223
  }
92
224
 
93
- // Get wallet
94
- let wallets;
225
+ // Cartoon plot readiness — block invalid/incomplete publish markdown.
226
+ // Derive cartoon status from server-side .story.json metadata (NOT the
227
+ // request body) so a direct API caller cannot bypass by omitting/faking
228
+ // contentType.
229
+ if (isPlot) {
230
+ const storyDir = path.join(STORIES_DIR, body.storyName);
231
+ const isCartoon = readStoryMeta(storyDir).contentType === "cartoon";
232
+ if (isCartoon) {
233
+ const plotFile = body.fileName.replace(/\.md$/, "");
234
+ let cutsFile;
235
+ try {
236
+ cutsFile = readCutsFile(storyDir, plotFile);
237
+ } catch (err) {
238
+ return c.json({ error: `Cannot publish: ${(err as Error).message}` }, 400);
239
+ }
240
+ if (!cutsFile) {
241
+ return c.json({ error: `Cannot publish: ${plotFile}.cuts.json not found. Generate cuts and upload final images first.` }, 400);
242
+ }
243
+
244
+ // Block on stale recorded asset paths — a cut whose cleanImagePath /
245
+ // finalImagePath points to a missing/invalid local file is not actually
246
+ // ready, and the generic markdown issues would obscure why (#302). Skip
247
+ // already-uploaded cuts: their content is on IPFS (uploadedUrl), so a
248
+ // missing LOCAL asset must not block re-publish.
249
+ const stale = findStaleAssetPaths(
250
+ cutsFile.cuts,
251
+ (rel) => isValidImageAsset(storyDir, rel),
252
+ ).filter((issue) => {
253
+ const cut = cutsFile.cuts.find((ct) => ct.id === issue.cutId);
254
+ return !cut?.uploadedUrl;
255
+ });
256
+ if (stale.length > 0) {
257
+ const messages = stale.map((s) => s.message);
258
+ return c.json(
259
+ { error: `Cartoon plot not ready to publish: ${messages.join("; ")}`, issues: messages },
260
+ 400,
261
+ );
262
+ }
263
+
264
+ const { ready, issues } = checkMarkdownReadiness(body.content, cutsFile.cuts);
265
+ if (!ready) {
266
+ return c.json({ error: `Cartoon plot not ready to publish: ${issues.join("; ")}`, issues }, 400);
267
+ }
268
+ }
269
+ }
270
+
271
+ // Get active wallet
272
+ let wallet;
95
273
  try {
96
- wallets = listAgentWallets();
274
+ const resolvedWallet = await resolveActiveWallet();
275
+ wallet = resolvedWallet.activeWallet;
276
+ if (!wallet) {
277
+ return c.json({
278
+ error: resolvedWallet.error || "No OWS wallet",
279
+ selectionRequired: resolvedWallet.selectionRequired,
280
+ wallets: resolvedWallet.wallets,
281
+ }, 400);
282
+ }
97
283
  } catch (err) {
98
- console.error("[publish/file] listAgentWallets error:", err);
284
+ console.error("[publish/file] resolveActiveWallet error:", err);
99
285
  return c.json({ error: `OWS wallet error: ${err instanceof Error ? err.message : String(err)}` }, 500);
100
286
  }
101
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
102
- if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
103
287
 
104
288
  console.log("[publish/file] Starting publish for", body.storyName, body.fileName, "wallet:", wallet.name);
105
289
 
@@ -116,7 +300,7 @@ publish.post("/file", async (c) => {
116
300
  body.storylineId,
117
301
  body.title,
118
302
  body.content,
119
- body.genre,
303
+ canonicalGenre,
120
304
  async (progress) => {
121
305
  await stream.writeSSE({ data: JSON.stringify(progress) });
122
306
  },
@@ -128,12 +312,13 @@ publish.post("/file", async (c) => {
128
312
  wallet.name,
129
313
  body.title,
130
314
  body.content,
131
- body.genre,
315
+ canonicalGenre,
132
316
  async (progress) => {
133
317
  await stream.writeSSE({ data: JSON.stringify(progress) });
134
318
  },
135
319
  body.language,
136
320
  body.isNsfw,
321
+ body.contentType,
137
322
  );
138
323
  }
139
324
 
@@ -199,11 +384,17 @@ publish.post("/retry-index", async (c) => {
199
384
  /** POST /api/publish/upload-cover — upload cover image with wallet signature */
200
385
  publish.post("/upload-cover", async (c) => {
201
386
  try {
202
- const wallets = listAgentWallets();
203
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
204
- if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
387
+ const resolvedWallet = await resolveActiveWallet();
388
+ const wallet = resolvedWallet.activeWallet;
389
+ if (!wallet) {
390
+ return c.json({
391
+ error: resolvedWallet.error || "No OWS wallet",
392
+ selectionRequired: resolvedWallet.selectionRequired,
393
+ wallets: resolvedWallet.wallets,
394
+ }, 400);
395
+ }
205
396
 
206
- const address = getBaseAddress(wallet);
397
+ const address = wallet.address;
207
398
  if (!address) return c.json({ error: "No EVM address on wallet" }, 400);
208
399
 
209
400
  const formData = await c.req.formData();
@@ -223,6 +414,9 @@ publish.post("/upload-cover", async (c) => {
223
414
  return c.json({ error: "Only WebP and JPEG images are accepted" }, 400);
224
415
  }
225
416
 
417
+ const bytesError = await imageBytesError(file);
418
+ if (bytesError) return c.json({ error: bytesError }, 400);
419
+
226
420
  const cid = await uploadCoverImage(wallet.name, address as `0x${string}`, file);
227
421
  return c.json({ cid });
228
422
  } catch (err: unknown) {
@@ -234,11 +428,17 @@ publish.post("/upload-cover", async (c) => {
234
428
  /** POST /api/publish/upload-plot-image — upload plot illustration with wallet signature */
235
429
  publish.post("/upload-plot-image", async (c) => {
236
430
  try {
237
- const wallets = listAgentWallets();
238
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
239
- if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
431
+ const resolvedWallet = await resolveActiveWallet();
432
+ const wallet = resolvedWallet.activeWallet;
433
+ if (!wallet) {
434
+ return c.json({
435
+ error: resolvedWallet.error || "No OWS wallet",
436
+ selectionRequired: resolvedWallet.selectionRequired,
437
+ wallets: resolvedWallet.wallets,
438
+ }, 400);
439
+ }
240
440
 
241
- const address = getBaseAddress(wallet);
441
+ const address = wallet.address;
242
442
  if (!address) return c.json({ error: "No EVM address on wallet" }, 400);
243
443
 
244
444
  const formData = await c.req.formData();
@@ -269,11 +469,17 @@ publish.post("/upload-plot-image", async (c) => {
269
469
  /** POST /api/publish/update-storyline — update storyline metadata with wallet signature */
270
470
  publish.post("/update-storyline", async (c) => {
271
471
  try {
272
- const wallets = listAgentWallets();
273
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
274
- if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
472
+ const resolvedWallet = await resolveActiveWallet();
473
+ const wallet = resolvedWallet.activeWallet;
474
+ if (!wallet) {
475
+ return c.json({
476
+ error: resolvedWallet.error || "No OWS wallet",
477
+ selectionRequired: resolvedWallet.selectionRequired,
478
+ wallets: resolvedWallet.wallets,
479
+ }, 400);
480
+ }
275
481
 
276
- const address = getBaseAddress(wallet);
482
+ const address = wallet.address;
277
483
  if (!address) return c.json({ error: "No EVM address on wallet" }, 400);
278
484
 
279
485
  const body = await c.req.json<{
@@ -288,13 +494,21 @@ publish.post("/update-storyline", async (c) => {
288
494
  return c.json({ error: "storylineId required" }, 400);
289
495
  }
290
496
 
497
+ // Canonicalize the genre before the signed on-chain metadata update so a
498
+ // natural label (e.g. "Sci-Fi") becomes "Science Fiction" rather than being
499
+ // rejected by PlotLink and leaving the public story UNCATEGORIZED (#412).
500
+ const genreResult = resolveGenre(body.genre);
501
+ if ("error" in genreResult) {
502
+ return c.json({ error: genreResult.error }, 400);
503
+ }
504
+
291
505
  await updateStoryline(
292
506
  wallet.name,
293
507
  address as `0x${string}`,
294
508
  body.storylineId,
295
509
  {
296
510
  coverCid: body.coverCid,
297
- genre: body.genre,
511
+ genre: genreResult.genre,
298
512
  language: body.language,
299
513
  isNsfw: body.isNsfw,
300
514
  },
@@ -2,28 +2,66 @@ import { Hono } from "hono";
2
2
  import { createPublicClient, createWalletClient, http, decodeEventLog } from "viem";
3
3
  import { base } from "viem/chains";
4
4
  import { erc8004Abi } from "../../packages/cli/src/sdk/abi";
5
- import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
5
+ import { resolveActiveWallet } from "../lib/active-wallet";
6
6
  import { createOwsAccount } from "../lib/publish";
7
- import { db } from "../db";
8
- import {
9
- signMessage as owsSignMsg,
10
- } from "@open-wallet-standard/core";
11
7
  import { CONFIG_DIR } from "../lib/paths";
12
8
  import fs from "fs";
13
9
  import path from "path";
14
10
 
15
11
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
12
+ type Config = Record<string, unknown>;
16
13
 
17
- function readConfig(): Record<string, unknown> {
14
+ function readConfig(): Config {
18
15
  try { return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch { return {}; }
19
16
  }
20
17
 
21
- function writeConfig(updates: Record<string, unknown>) {
18
+ function writeConfig(updates: Config) {
22
19
  const config = readConfig();
23
20
  Object.assign(config, updates);
24
21
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
25
22
  }
26
23
 
24
+ function normalizeAddress(address: unknown): string | null {
25
+ return typeof address === "string" && /^0x[a-fA-F0-9]{40}$/.test(address)
26
+ ? address.toLowerCase()
27
+ : null;
28
+ }
29
+
30
+ function getWalletAgentConfig(
31
+ config: Config,
32
+ wallet: { walletId?: string; name: string; address: string },
33
+ selectableWalletCount: number,
34
+ ): Config | null {
35
+ if (!config.agentId) return null;
36
+
37
+ const cachedAddress = normalizeAddress(config.agentWalletAddress);
38
+ const activeAddress = normalizeAddress(wallet.address);
39
+ if (cachedAddress && activeAddress) {
40
+ return cachedAddress === activeAddress ? config : null;
41
+ }
42
+
43
+ if (typeof config.agentWalletId === "string" && wallet.walletId) {
44
+ return config.agentWalletId === wallet.walletId ? config : null;
45
+ }
46
+
47
+ if (typeof config.agentWalletName === "string") {
48
+ return config.agentWalletName === wallet.name ? config : null;
49
+ }
50
+
51
+ // Backwards compatibility for pre-#196 installs: an unscoped cache can only
52
+ // be trusted when there is no wallet-switching ambiguity.
53
+ return selectableWalletCount <= 1 ? config : null;
54
+ }
55
+
56
+ function walletAgentConfig(wallet: { walletId?: string; name: string; address: string }, updates: Config): Config {
57
+ return {
58
+ ...updates,
59
+ agentWalletAddress: wallet.address.toLowerCase(),
60
+ agentWalletName: wallet.name,
61
+ ...(wallet.walletId ? { agentWalletId: wallet.walletId } : {}),
62
+ };
63
+ }
64
+
27
65
  const ERC_8004 = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as const;
28
66
  const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
29
67
 
@@ -43,33 +81,37 @@ settings.post("/generate-binding", async (c) => {
43
81
  }
44
82
 
45
83
  try {
46
- const wallets = listAgentWallets();
47
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
48
- if (!wallet) return c.json({ error: "No OWS wallet found. Create one in Wallet settings first." }, 400);
84
+ const resolvedWallet = await resolveActiveWallet();
85
+ const wallet = resolvedWallet.activeWallet;
86
+ if (!wallet) {
87
+ return c.json({
88
+ error: resolvedWallet.error || "No OWS wallet found. Create one in Wallet settings first.",
89
+ selectionRequired: resolvedWallet.selectionRequired,
90
+ wallets: resolvedWallet.wallets,
91
+ }, 400);
92
+ }
49
93
 
50
- const owsWallet = getBaseAddress(wallet);
94
+ const owsWallet = wallet.address;
51
95
  if (!owsWallet) return c.json({ error: "No EVM address on wallet" }, 400);
52
96
 
53
97
  const message = `I authorize ${body.humanWallet} as my PlotLink owner. Wallet: ${owsWallet}`;
54
- const passphrase = process.env.OWS_PASSPHRASE;
55
-
56
- const result = owsSignMsg(wallet.name, "eip155:8453", message, passphrase);
57
- const signature = result.signature.startsWith("0x") ? result.signature : `0x${result.signature}`;
98
+ const account = createOwsAccount(wallet.name, owsWallet as `0x${string}`);
99
+ const signature = await account.signMessage({ message });
58
100
 
59
101
  // Include agent data from config.json if available
60
- const config = readConfig();
102
+ const config = getWalletAgentConfig(readConfig(), wallet, resolvedWallet.wallets.filter((w) => w.address).length);
61
103
 
62
104
  return c.json({
63
105
  message,
64
106
  signature,
65
107
  owsWallet,
66
- agentId: config.agentId ? Number(config.agentId) : undefined,
67
- agentName: (config.agentName as string) || undefined,
68
- agentDescription: (config.agentDescription as string) || undefined,
69
- agentGenre: (config.agentGenre as string) || undefined,
70
- agentLlmModel: (config.agentLlmModel as string) || undefined,
71
- agentRegisteredBy: (config.agentRegisteredBy as string) || undefined,
72
- agentRegisteredAt: (config.agentRegisteredAt as string) || undefined,
108
+ agentId: config?.agentId ? Number(config.agentId) : undefined,
109
+ agentName: (config?.agentName as string) || undefined,
110
+ agentDescription: (config?.agentDescription as string) || undefined,
111
+ agentGenre: (config?.agentGenre as string) || undefined,
112
+ agentLlmModel: (config?.agentLlmModel as string) || undefined,
113
+ agentRegisteredBy: (config?.agentRegisteredBy as string) || undefined,
114
+ agentRegisteredAt: (config?.agentRegisteredAt as string) || undefined,
73
115
  });
74
116
  } catch (err: unknown) {
75
117
  const msg = err instanceof Error ? err.message : "Failed to generate binding proof";
@@ -89,11 +131,17 @@ settings.post("/register-agent", async (c) => {
89
131
  }
90
132
 
91
133
  try {
92
- const wallets = listAgentWallets();
93
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
94
- if (!wallet) return c.json({ error: "No OWS wallet found. Create one in Wallet settings first." }, 400);
134
+ const resolvedWallet = await resolveActiveWallet();
135
+ const wallet = resolvedWallet.activeWallet;
136
+ if (!wallet) {
137
+ return c.json({
138
+ error: resolvedWallet.error || "No OWS wallet found. Create one in Wallet settings first.",
139
+ selectionRequired: resolvedWallet.selectionRequired,
140
+ wallets: resolvedWallet.wallets,
141
+ }, 400);
142
+ }
95
143
 
96
- const owsAddress = getBaseAddress(wallet);
144
+ const owsAddress = wallet.address;
97
145
  if (!owsAddress) return c.json({ error: "No EVM address on wallet" }, 400);
98
146
 
99
147
  // Check if already registered
@@ -160,7 +208,7 @@ settings.post("/register-agent", async (c) => {
160
208
  }
161
209
 
162
210
  // Cache full tokenURI data in config.json (survives npx reinstalls, no Prisma dependency)
163
- writeConfig({
211
+ writeConfig(walletAgentConfig(wallet, {
164
212
  agentId,
165
213
  agentName: body.name.trim(),
166
214
  agentDescription: body.description.trim(),
@@ -168,7 +216,7 @@ settings.post("/register-agent", async (c) => {
168
216
  agentLlmModel: "Claude",
169
217
  agentRegisteredBy: "plotlink-ows",
170
218
  agentRegisteredAt: registeredAt,
171
- });
219
+ }));
172
220
 
173
221
  return c.json({
174
222
  agentId,
@@ -184,16 +232,23 @@ settings.post("/register-agent", async (c) => {
184
232
  /** GET /api/settings/link-status — check if OWS wallet is registered on ERC-8004 */
185
233
  settings.get("/link-status", async (c) => {
186
234
  try {
187
- const wallets = listAgentWallets();
188
- const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
189
- if (!wallet) return c.json({ linked: false, error: "No wallet" });
235
+ const resolvedWallet = await resolveActiveWallet();
236
+ const wallet = resolvedWallet.activeWallet;
237
+ if (!wallet) {
238
+ return c.json({
239
+ linked: false,
240
+ error: resolvedWallet.error || "No wallet",
241
+ selectionRequired: resolvedWallet.selectionRequired,
242
+ wallets: resolvedWallet.wallets,
243
+ });
244
+ }
190
245
 
191
- const address = getBaseAddress(wallet);
246
+ const address = wallet.address;
192
247
  if (!address) return c.json({ linked: false, error: "No EVM address" });
193
248
 
194
249
  // Check config.json cache first (survives npx reinstalls + RPC rate limits)
195
- const config = readConfig();
196
- if (config.agentId) {
250
+ const config = getWalletAgentConfig(readConfig(), wallet, resolvedWallet.wallets.filter((w) => w.address).length);
251
+ if (config?.agentId) {
197
252
  return c.json({ linked: true, agentId: Number(config.agentId), owsWallet: address });
198
253
  }
199
254
 
@@ -207,7 +262,7 @@ settings.get("/link-status", async (c) => {
207
262
  }) as bigint;
208
263
 
209
264
  if (agentId > 0n) {
210
- writeConfig({ agentId: Number(agentId) });
265
+ writeConfig(walletAgentConfig(wallet, { agentId: Number(agentId) }));
211
266
  return c.json({ linked: true, agentId: Number(agentId), owsWallet: address });
212
267
  }
213
268
  } catch { /* agentIdByWallet may revert if not bound */ }
@@ -235,7 +290,7 @@ settings.get("/link-status", async (c) => {
235
290
  } catch { /* ERC-721 Enumerable not supported */ }
236
291
 
237
292
  if (agentId !== undefined) {
238
- writeConfig({ agentId });
293
+ writeConfig(walletAgentConfig(wallet, { agentId }));
239
294
  }
240
295
  return c.json({ linked: true, agentId, owsWallet: address });
241
296
  }