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.
- package/README.md +4 -0
- package/app/lib/active-wallet.ts +260 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +813 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +8 -1
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +242 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/dashboard.ts +6 -4
- package/app/routes/publish.ts +259 -45
- package/app/routes/settings.ts +92 -37
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/routes/wallet.ts +58 -30
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonNextAction.tsx +145 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1337 -0
- package/app/web/components/Dashboard.tsx +15 -6
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1182 -0
- package/app/web/components/PreviewPanel.tsx +952 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +745 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +446 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +156 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-DxATSk7X.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
package/app/routes/publish.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
14
|
-
const wallet =
|
|
54
|
+
const resolvedWallet = await resolveActiveWallet();
|
|
55
|
+
const wallet = resolvedWallet.activeWallet;
|
|
15
56
|
if (!wallet) {
|
|
16
|
-
return c.json({
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
error:
|
|
55
|
-
?
|
|
56
|
-
:
|
|
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
|
-
//
|
|
94
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
203
|
-
const wallet =
|
|
204
|
-
if (!wallet)
|
|
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 =
|
|
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
|
|
238
|
-
const wallet =
|
|
239
|
-
if (!wallet)
|
|
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 =
|
|
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
|
|
273
|
-
const wallet =
|
|
274
|
-
if (!wallet)
|
|
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 =
|
|
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:
|
|
511
|
+
genre: genreResult.genre,
|
|
298
512
|
language: body.language,
|
|
299
513
|
isNsfw: body.isNsfw,
|
|
300
514
|
},
|
package/app/routes/settings.ts
CHANGED
|
@@ -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 {
|
|
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():
|
|
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:
|
|
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
|
|
47
|
-
const wallet =
|
|
48
|
-
if (!wallet)
|
|
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 =
|
|
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
|
|
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
|
|
67
|
-
agentName: (config
|
|
68
|
-
agentDescription: (config
|
|
69
|
-
agentGenre: (config
|
|
70
|
-
agentLlmModel: (config
|
|
71
|
-
agentRegisteredBy: (config
|
|
72
|
-
agentRegisteredAt: (config
|
|
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
|
|
93
|
-
const wallet =
|
|
94
|
-
if (!wallet)
|
|
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 =
|
|
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
|
|
188
|
-
const wallet =
|
|
189
|
-
if (!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 =
|
|
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
|
|
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
|
}
|