plotlink-ows 1.0.24 → 1.0.26

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 CHANGED
@@ -7,6 +7,7 @@
7
7
  <p>
8
8
  <a href="https://plotlink.xyz"><strong>Live App</strong></a> ·
9
9
  <a href="#-quick-start"><strong>Quick Start</strong></a> ·
10
+ <a href="#-wallet-setup"><strong>Wallet Setup</strong></a> ·
10
11
  <a href="#how-it-works"><strong>How it Works</strong></a> ·
11
12
  <a href="https://docs.openwallet.sh/"><strong>OWS Docs</strong></a>
12
13
  </p>
@@ -116,6 +117,49 @@ npx plotlink-ows status # Show config + wallet info
116
117
 
117
118
  ---
118
119
 
120
+ ## 🔑 Wallet Setup
121
+
122
+ PlotLink OWS uses an encrypted local wallet via Open Wallet Standard. No raw private keys are exposed to scripts or environment variables.
123
+
124
+ ### Initial Setup
125
+
126
+ ```bash
127
+ npx plotlink-ows init
128
+ ```
129
+
130
+ The setup wizard will:
131
+ 1. Ask you to create a passphrase (encrypts your wallet at rest)
132
+ 2. Generate a new OWS wallet in `~/.ows/`
133
+ 3. Display your Base (L2) wallet address
134
+
135
+ ### Funding Your Wallet
136
+
137
+ Send a small amount of ETH on **Base** to your wallet address. Publishing costs less than $0.05 per story.
138
+
139
+ You can bridge ETH from Ethereum mainnet to Base using the [official Base Bridge](https://bridge.base.org) or any supported bridge.
140
+
141
+ ### Environment Variables
142
+
143
+ | Variable | Required | Description |
144
+ |----------|----------|-------------|
145
+ | `OWS_PASSPHRASE` | Yes | Your wallet encryption passphrase |
146
+ | `NEXT_PUBLIC_RPC_URL` | No | Custom Base RPC URL (defaults to `https://mainnet.base.org`) |
147
+ | `NEXT_PUBLIC_APP_URL` | No | PlotLink API URL (defaults to `https://plotlink.xyz`) |
148
+
149
+ Copy `.env.example` to `.env` and fill in your values:
150
+
151
+ ```bash
152
+ cp .env.example .env
153
+ ```
154
+
155
+ > **Security best practices:**
156
+ > - Never share your passphrase or wallet files with anyone
157
+ > - Use a dedicated wallet for agent operations — do not reuse your main wallet
158
+ > - Never commit `.env` or wallet files to version control
159
+ > - Store backups of `~/.ows/` in a secure, offline location
160
+
161
+ ---
162
+
119
163
  ## 🏗️ Architecture
120
164
 
121
165
  ```
@@ -142,9 +142,9 @@ async function indexWithDelayAndRetry(
142
142
  * Upload story content to IPFS via PlotLink's API (plotlink.xyz/api/upload).
143
143
  * PlotLink handles Filebase credentials server-side.
144
144
  */
145
- export async function uploadToIPFS(content: string, title: string, genre?: string): Promise<string> {
145
+ export async function uploadToIPFS(content: string, title: string, genre?: string, language?: string): Promise<string> {
146
146
  const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
147
- const metadata = JSON.stringify({ title, genre, content });
147
+ const metadata = JSON.stringify({ title, genre, language, content });
148
148
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);
149
149
  const key = `plotlink/storylines/${Date.now()}-${slug}.json`;
150
150
 
@@ -293,10 +293,16 @@ export async function publishStoryline(
293
293
  content: string,
294
294
  genre: string | undefined,
295
295
  onProgress: (progress: PublishProgress) => void,
296
+ language?: string,
297
+ isNsfw?: boolean,
296
298
  ): Promise<PublishResult> {
299
+ // Normalize optional fields to backwards-compatible defaults
300
+ const normalizedLanguage = language || "English";
301
+ const normalizedIsNsfw = isNsfw ?? false;
302
+
297
303
  // Step 1: Upload to IPFS
298
304
  onProgress({ step: "uploading", message: "Uploading story to IPFS..." });
299
- const contentCid = await uploadToIPFS(content, title, genre);
305
+ const contentCid = await uploadToIPFS(content, title, genre, normalizedLanguage);
300
306
 
301
307
  // Step 2: Compute content hash + get creation fee
302
308
  const contentHash = keccak256(toBytes(content));
@@ -334,7 +340,7 @@ export async function publishStoryline(
334
340
  // Streams "Indexing…" progress so the user does not escalate to Retry Publish.
335
341
  const indexError = await indexWithDelayAndRetry(
336
342
  "storyline",
337
- { txHash, content, genre },
343
+ { txHash, content, genre, language: normalizedLanguage, isNsfw: normalizedIsNsfw },
338
344
  onProgress,
339
345
  txHash,
340
346
  contentCid,
@@ -361,10 +367,13 @@ export async function publishPlot(
361
367
  content: string,
362
368
  genre: string | undefined,
363
369
  onProgress: (progress: PublishProgress) => void,
370
+ language?: string,
364
371
  ): Promise<PublishResult> {
372
+ const normalizedLanguage = language || "English";
373
+
365
374
  // Step 1: Upload to IPFS
366
375
  onProgress({ step: "uploading", message: "Uploading plot to IPFS..." });
367
- const contentCid = await uploadToIPFS(content, title, genre);
376
+ const contentCid = await uploadToIPFS(content, title, genre, normalizedLanguage);
368
377
 
369
378
  // Step 2: Compute content hash
370
379
  const contentHash = keccak256(toBytes(content));
@@ -415,3 +424,74 @@ export async function publishPlot(
415
424
 
416
425
  return { txHash, contentCid, storylineId, plotIndex: confirmation.plotIndex >= 0 ? confirmation.plotIndex : undefined, gasCost: confirmation.gasCost, indexError };
417
426
  }
427
+
428
+ /**
429
+ * Upload a cover image to PlotLink via signed API call.
430
+ * Uses createOwsAccount for signing (not raw owsSignMsg).
431
+ * Returns the IPFS CID of the uploaded image.
432
+ */
433
+ export async function uploadCoverImage(
434
+ walletName: string,
435
+ walletAddress: `0x${string}`,
436
+ imageFile: File,
437
+ ): Promise<string> {
438
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
439
+ const account = createOwsAccount(walletName, walletAddress);
440
+
441
+ const timestamp = Date.now();
442
+ const message = `PlotLink: Upload cover image\nTimestamp: ${timestamp}`;
443
+ const signature = await account.signMessage({ message });
444
+
445
+ const formData = new FormData();
446
+ formData.append("file", imageFile);
447
+ formData.append("message", message);
448
+ formData.append("signature", signature);
449
+
450
+ const res = await fetch(`${PLOTLINK_URL}/api/upload-cover`, {
451
+ method: "POST",
452
+ body: formData,
453
+ });
454
+
455
+ if (!res.ok) {
456
+ const err = await res.json().catch(() => ({})) as Record<string, string>;
457
+ throw new Error(err.error || `Cover upload failed: HTTP ${res.status}`);
458
+ }
459
+
460
+ const data = await res.json() as { cid: string };
461
+ return data.cid;
462
+ }
463
+
464
+ /**
465
+ * Update storyline metadata (cover, genre, language, NSFW) on PlotLink via signed API call.
466
+ * Uses createOwsAccount for signing (not raw owsSignMsg).
467
+ * Message format must match: /^PlotLink: Update storyline #(\d+)\nTimestamp: (\d+)$/
468
+ */
469
+ export async function updateStoryline(
470
+ walletName: string,
471
+ walletAddress: `0x${string}`,
472
+ storylineId: number,
473
+ updates: { coverCid?: string | null; genre?: string; language?: string; isNsfw?: boolean },
474
+ ): Promise<void> {
475
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
476
+ const account = createOwsAccount(walletName, walletAddress);
477
+
478
+ const timestamp = Date.now();
479
+ const message = `PlotLink: Update storyline #${storylineId}\nTimestamp: ${timestamp}`;
480
+ const signature = await account.signMessage({ message });
481
+
482
+ const res = await fetch(`${PLOTLINK_URL}/api/storyline/update`, {
483
+ method: "POST",
484
+ headers: { "Content-Type": "application/json" },
485
+ body: JSON.stringify({
486
+ storylineId,
487
+ signature,
488
+ message,
489
+ ...updates,
490
+ }),
491
+ });
492
+
493
+ if (!res.ok) {
494
+ const err = await res.json().catch(() => ({})) as Record<string, string>;
495
+ throw new Error(err.error || `Storyline update failed: HTTP ${res.status}`);
496
+ }
497
+ }
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
- import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost } from "../lib/publish";
3
+ import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, updateStoryline } from "../lib/publish";
4
4
  import { keccak256, toBytes } from "viem";
5
5
  import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
6
6
 
@@ -71,6 +71,8 @@ publish.post("/file", async (c) => {
71
71
  title: string;
72
72
  content: string;
73
73
  genre?: string;
74
+ language?: string;
75
+ isNsfw?: boolean;
74
76
  storylineId?: number;
75
77
  }>();
76
78
 
@@ -118,6 +120,7 @@ publish.post("/file", async (c) => {
118
120
  async (progress) => {
119
121
  await stream.writeSSE({ data: JSON.stringify(progress) });
120
122
  },
123
+ body.language,
121
124
  );
122
125
  } else {
123
126
  // Create new storyline (genesis or first file)
@@ -129,6 +132,8 @@ publish.post("/file", async (c) => {
129
132
  async (progress) => {
130
133
  await stream.writeSSE({ data: JSON.stringify(progress) });
131
134
  },
135
+ body.language,
136
+ body.isNsfw,
132
137
  );
133
138
  }
134
139
 
@@ -191,4 +196,79 @@ publish.post("/retry-index", async (c) => {
191
196
  }
192
197
  });
193
198
 
199
+ /** POST /api/publish/upload-cover — upload cover image with wallet signature */
200
+ publish.post("/upload-cover", async (c) => {
201
+ 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);
205
+
206
+ const address = getBaseAddress(wallet);
207
+ if (!address) return c.json({ error: "No EVM address on wallet" }, 400);
208
+
209
+ const formData = await c.req.formData();
210
+ const file = formData.get("file");
211
+ if (!file || !(file instanceof File)) {
212
+ return c.json({ error: "No image file provided" }, 400);
213
+ }
214
+
215
+ // Validate file size (500KB max)
216
+ if (file.size > 500 * 1024) {
217
+ return c.json({ error: "Image exceeds 500KB limit" }, 400);
218
+ }
219
+
220
+ // Validate file type
221
+ if (!file.type.startsWith("image/")) {
222
+ return c.json({ error: "File must be an image (WebP or JPEG recommended)" }, 400);
223
+ }
224
+
225
+ const cid = await uploadCoverImage(wallet.name, address as `0x${string}`, file);
226
+ return c.json({ cid });
227
+ } catch (err: unknown) {
228
+ const message = err instanceof Error ? err.message : "Cover upload failed";
229
+ return c.json({ error: message }, 500);
230
+ }
231
+ });
232
+
233
+ /** POST /api/publish/update-storyline — update storyline metadata with wallet signature */
234
+ publish.post("/update-storyline", async (c) => {
235
+ try {
236
+ const wallets = listAgentWallets();
237
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
238
+ if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
239
+
240
+ const address = getBaseAddress(wallet);
241
+ if (!address) return c.json({ error: "No EVM address on wallet" }, 400);
242
+
243
+ const body = await c.req.json<{
244
+ storylineId: number;
245
+ coverCid?: string | null;
246
+ genre?: string;
247
+ language?: string;
248
+ isNsfw?: boolean;
249
+ }>();
250
+
251
+ if (!body.storylineId) {
252
+ return c.json({ error: "storylineId required" }, 400);
253
+ }
254
+
255
+ await updateStoryline(
256
+ wallet.name,
257
+ address as `0x${string}`,
258
+ body.storylineId,
259
+ {
260
+ coverCid: body.coverCid,
261
+ genre: body.genre,
262
+ language: body.language,
263
+ isNsfw: body.isNsfw,
264
+ },
265
+ );
266
+
267
+ return c.json({ ok: true });
268
+ } catch (err: unknown) {
269
+ const message = err instanceof Error ? err.message : "Update failed";
270
+ return c.json({ error: message }, 500);
271
+ }
272
+ });
273
+
194
274
  export { publish as publishRoutes };
@@ -23,6 +23,7 @@ interface FileStatus {
23
23
  publishedAt?: string;
24
24
  gasCost?: string;
25
25
  indexError?: string;
26
+ authorAddress?: string;
26
27
  }
27
28
 
28
29
  interface StoryInfo {
@@ -243,6 +244,7 @@ stories.post("/:name/:file/publish-status", async (c) => {
243
244
  contentCid: string;
244
245
  gasCost?: string;
245
246
  indexError?: string;
247
+ authorAddress?: string;
246
248
  }>();
247
249
 
248
250
  const status = readPublishStatus(storyDir);
@@ -255,6 +257,7 @@ stories.post("/:name/:file/publish-status", async (c) => {
255
257
  plotIndex: body.plotIndex ?? existing?.plotIndex,
256
258
  contentCid: body.contentCid || existing?.contentCid,
257
259
  gasCost: body.gasCost || existing?.gasCost,
260
+ authorAddress: body.authorAddress || existing?.authorAddress,
258
261
  publishedAt: new Date().toISOString(),
259
262
  ...(body.indexError ? { indexError: body.indexError } : {}),
260
263
  };
@@ -174,6 +174,17 @@ export function Layout({ token, onLogout }: { token: string; onLogout: () => voi
174
174
  </ol>
175
175
  </div>
176
176
 
177
+ <div className="text-center">
178
+ <a
179
+ href="https://github.com/realproject7/plotlink-ows#-wallet-setup"
180
+ target="_blank"
181
+ rel="noopener noreferrer"
182
+ className="text-xs text-muted hover:text-accent underline transition-colors"
183
+ >
184
+ Wallet Setup Guide
185
+ </a>
186
+ </div>
187
+
177
188
  <WalletCard token={token} />
178
189
  </div>
179
190
  )}