plotlink-ows 0.1.13

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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -0
  3. package/app/db.ts +8 -0
  4. package/app/lib/llm-client.ts +265 -0
  5. package/app/lib/paths.ts +11 -0
  6. package/app/lib/publish.ts +204 -0
  7. package/app/lib/writer-prompt.ts +44 -0
  8. package/app/node_modules/.prisma/local-client/client.d.ts +1 -0
  9. package/app/node_modules/.prisma/local-client/client.js +5 -0
  10. package/app/node_modules/.prisma/local-client/default.d.ts +1 -0
  11. package/app/node_modules/.prisma/local-client/default.js +5 -0
  12. package/app/node_modules/.prisma/local-client/edge.d.ts +1 -0
  13. package/app/node_modules/.prisma/local-client/edge.js +184 -0
  14. package/app/node_modules/.prisma/local-client/index-browser.js +173 -0
  15. package/app/node_modules/.prisma/local-client/index.d.ts +3304 -0
  16. package/app/node_modules/.prisma/local-client/index.js +207 -0
  17. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  18. package/app/node_modules/.prisma/local-client/package.json +183 -0
  19. package/app/node_modules/.prisma/local-client/query_engine_bg.js +2 -0
  20. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  21. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +35 -0
  22. package/app/node_modules/.prisma/local-client/runtime/edge.js +35 -0
  23. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +370 -0
  24. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +17 -0
  25. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +3982 -0
  26. package/app/node_modules/.prisma/local-client/runtime/library.js +147 -0
  27. package/app/node_modules/.prisma/local-client/runtime/react-native.js +84 -0
  28. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +85 -0
  29. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +38 -0
  30. package/app/node_modules/.prisma/local-client/schema.prisma +21 -0
  31. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +5 -0
  32. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +5 -0
  33. package/app/node_modules/.prisma/local-client/wasm.d.ts +1 -0
  34. package/app/node_modules/.prisma/local-client/wasm.js +191 -0
  35. package/app/prisma/schema.prisma +57 -0
  36. package/app/routes/auth.ts +173 -0
  37. package/app/routes/chat.ts +135 -0
  38. package/app/routes/config.ts +210 -0
  39. package/app/routes/dashboard.ts +186 -0
  40. package/app/routes/oauth.ts +150 -0
  41. package/app/routes/publish.ts +112 -0
  42. package/app/routes/wallet.ts +99 -0
  43. package/app/server.ts +154 -0
  44. package/app/vite.config.ts +19 -0
  45. package/app/web/App.tsx +102 -0
  46. package/app/web/components/Chat.tsx +272 -0
  47. package/app/web/components/Dashboard.tsx +222 -0
  48. package/app/web/components/LLMSetup.tsx +291 -0
  49. package/app/web/components/Layout.tsx +235 -0
  50. package/app/web/components/Login.tsx +62 -0
  51. package/app/web/components/Publish.tsx +245 -0
  52. package/app/web/components/Settings.tsx +175 -0
  53. package/app/web/components/Setup.tsx +84 -0
  54. package/app/web/components/WalletCard.tsx +117 -0
  55. package/app/web/dist/assets/index-C9kXlYO_.css +2 -0
  56. package/app/web/dist/assets/index-CJiiaLHs.js +9 -0
  57. package/app/web/dist/index.html +16 -0
  58. package/app/web/index.html +15 -0
  59. package/app/web/main.tsx +10 -0
  60. package/app/web/plotlink-logo.svg +5 -0
  61. package/app/web/styles.css +51 -0
  62. package/bin/plotlink-ows.js +394 -0
  63. package/lib/ows/index.ts +3 -0
  64. package/lib/ows/policy.ts +68 -0
  65. package/lib/ows/types.ts +14 -0
  66. package/lib/ows/wallet.ts +70 -0
  67. package/package.json +79 -0
  68. package/packages/cli/node_modules/commander/LICENSE +22 -0
  69. package/packages/cli/node_modules/commander/Readme.md +1149 -0
  70. package/packages/cli/node_modules/commander/esm.mjs +16 -0
  71. package/packages/cli/node_modules/commander/index.js +24 -0
  72. package/packages/cli/node_modules/commander/lib/argument.js +149 -0
  73. package/packages/cli/node_modules/commander/lib/command.js +2662 -0
  74. package/packages/cli/node_modules/commander/lib/error.js +39 -0
  75. package/packages/cli/node_modules/commander/lib/help.js +709 -0
  76. package/packages/cli/node_modules/commander/lib/option.js +367 -0
  77. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +101 -0
  78. package/packages/cli/node_modules/commander/package-support.json +16 -0
  79. package/packages/cli/node_modules/commander/package.json +82 -0
  80. package/packages/cli/node_modules/commander/typings/esm.d.mts +3 -0
  81. package/packages/cli/node_modules/commander/typings/index.d.ts +1045 -0
  82. package/packages/cli/node_modules/resolve-from/index.d.ts +31 -0
  83. package/packages/cli/node_modules/resolve-from/index.js +47 -0
  84. package/packages/cli/node_modules/resolve-from/license +9 -0
  85. package/packages/cli/node_modules/resolve-from/package.json +36 -0
  86. package/packages/cli/node_modules/resolve-from/readme.md +72 -0
  87. package/packages/cli/node_modules/tsup/LICENSE +21 -0
  88. package/packages/cli/node_modules/tsup/README.md +75 -0
  89. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +13 -0
  90. package/packages/cli/node_modules/tsup/assets/esm_shims.js +9 -0
  91. package/packages/cli/node_modules/tsup/assets/package.json +3 -0
  92. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +153 -0
  93. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +42 -0
  94. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +6 -0
  95. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +352 -0
  96. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +203 -0
  97. package/packages/cli/node_modules/tsup/dist/cli-default.js +12 -0
  98. package/packages/cli/node_modules/tsup/dist/cli-main.js +8 -0
  99. package/packages/cli/node_modules/tsup/dist/cli-node.js +14 -0
  100. package/packages/cli/node_modules/tsup/dist/index.d.ts +511 -0
  101. package/packages/cli/node_modules/tsup/dist/index.js +1711 -0
  102. package/packages/cli/node_modules/tsup/dist/rollup.js +6949 -0
  103. package/packages/cli/node_modules/tsup/package.json +99 -0
  104. package/packages/cli/node_modules/tsup/schema.json +362 -0
  105. package/packages/cli/package.json +35 -0
  106. package/packages/cli/src/commands/agent-register.ts +77 -0
  107. package/packages/cli/src/commands/chain.ts +29 -0
  108. package/packages/cli/src/commands/claim.ts +70 -0
  109. package/packages/cli/src/commands/create.ts +34 -0
  110. package/packages/cli/src/commands/status.ts +201 -0
  111. package/packages/cli/src/config.ts +103 -0
  112. package/packages/cli/src/index.ts +21 -0
  113. package/packages/cli/src/sdk/abi.ts +222 -0
  114. package/packages/cli/src/sdk/client.ts +713 -0
  115. package/packages/cli/src/sdk/constants.ts +56 -0
  116. package/packages/cli/src/sdk/index.ts +46 -0
  117. package/packages/cli/src/sdk/ipfs.ts +88 -0
  118. package/packages/cli/src/sdk.ts +36 -0
  119. package/packages/cli/tsconfig.json +20 -0
  120. package/packages/cli/tsup.config.ts +14 -0
  121. package/public/.well-known/farcaster.json +38 -0
  122. package/public/basescan-icon.svg +4 -0
  123. package/public/embed-image.png +0 -0
  124. package/public/favicon.png +0 -0
  125. package/public/hunt-token.svg +11 -0
  126. package/public/icon-192.png +0 -0
  127. package/public/icon.png +0 -0
  128. package/public/manifest.json +26 -0
  129. package/public/mc-icon-light.svg +12 -0
  130. package/public/og-image.png +0 -0
  131. package/public/plotlink-logo-symbol.svg +5 -0
  132. package/public/plotlink-logo.svg +5 -0
  133. package/public/screenshot-1.png +0 -0
  134. package/public/screenshot-2.png +0 -0
  135. package/public/screenshot-3.png +0 -0
  136. package/public/splash.png +0 -0
  137. package/public/wide-banner.png +0 -0
  138. package/scripts/backfill-trade-prices.ts +97 -0
  139. package/scripts/backfill-usd-rates.ts +220 -0
  140. package/scripts/e2e-verify.ts +1100 -0
  141. package/scripts/ows-smoke-test.ts +37 -0
  142. package/scripts/score-users.mjs +203 -0
@@ -0,0 +1,210 @@
1
+ import { Hono } from "hono";
2
+ import fs from "fs";
3
+ import { AGENT_CONFIG_FILE, ENV_FILE } from "../lib/paths";
4
+
5
+ const configPath = AGENT_CONFIG_FILE;
6
+ const envPath = ENV_FILE;
7
+
8
+ const config = new Hono();
9
+
10
+ /** Provider catalog with metadata */
11
+ const PROVIDERS = [
12
+ { id: "anthropic", name: "Anthropic", envKeys: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"], models: ["claude-sonnet-4-6", "claude-haiku-4-5-20251001", "claude-opus-4-6"], tag: "recommended" },
13
+ { id: "openai", name: "OpenAI", envKeys: ["OPENAI_API_KEY", "OPENAI_OAUTH_TOKEN"], models: ["gpt-4.1", "gpt-4.1-mini", "o3-mini"], tag: null },
14
+ { id: "gemini", name: "Google Gemini", envKeys: ["GEMINI_API_KEY"], models: ["gemini-2.5-flash", "gemini-2.5-pro"], tag: null },
15
+ { id: "local", name: "Local (Ollama/LM Studio)", envKeys: [], models: [], tag: "free" },
16
+ ];
17
+
18
+ /** Check if any env key for a provider is set */
19
+ function isProviderConfigured(p: typeof PROVIDERS[number]): boolean {
20
+ return p.envKeys.some((k) => !!process.env[k]);
21
+ }
22
+
23
+ /** Get the active credential for a provider */
24
+ function getProviderCredential(p: typeof PROVIDERS[number]): string | null {
25
+ for (const k of p.envKeys) {
26
+ if (process.env[k]) return process.env[k]!;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function readConfig(): Record<string, unknown> {
32
+ try {
33
+ if (fs.existsSync(configPath)) {
34
+ return JSON.parse(fs.readFileSync(configPath, "utf-8"));
35
+ }
36
+ } catch { /* ignore */ }
37
+ return {};
38
+ }
39
+
40
+ function writeConfig(cfg: Record<string, unknown>) {
41
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
42
+ }
43
+
44
+ function writeEnvVar(key: string, value: string) {
45
+ if (fs.existsSync(envPath)) {
46
+ const content = fs.readFileSync(envPath, "utf-8");
47
+ const regex = new RegExp(`^${key}=.*$`, "m");
48
+ if (regex.test(content)) {
49
+ fs.writeFileSync(envPath, content.replace(regex, `${key}=${value}`));
50
+ } else {
51
+ fs.appendFileSync(envPath, `\n${key}=${value}\n`);
52
+ }
53
+ } else {
54
+ fs.writeFileSync(envPath, `${key}=${value}\n`);
55
+ }
56
+ process.env[key] = value;
57
+ }
58
+
59
+ /** GET /api/config/llm — current LLM config */
60
+ config.get("/llm", (c) => {
61
+ const cfg = readConfig() as { llm?: Record<string, unknown> };
62
+ const llm = cfg.llm || {};
63
+
64
+ // Check which providers are configured
65
+ const configured = PROVIDERS.filter((p) => {
66
+ if (p.id === "local") return !!(llm as Record<string, unknown>).local;
67
+ return isProviderConfigured(p);
68
+ }).map((p) => p.id);
69
+
70
+ return c.json({ llm, configured });
71
+ });
72
+
73
+ /** GET /api/config/llm/providers — provider catalog */
74
+ config.get("/llm/providers", (c) => {
75
+ return c.json(
76
+ PROVIDERS.map((p) => ({
77
+ ...p,
78
+ configured: isProviderConfigured(p),
79
+ })),
80
+ );
81
+ });
82
+
83
+ /** POST /api/config/llm — save LLM config */
84
+ config.post("/llm", async (c) => {
85
+ const body = await c.req.json<{
86
+ provider: string;
87
+ model: string;
88
+ apiKey?: string;
89
+ baseUrl?: string;
90
+ apiType?: string;
91
+ spendCap?: number;
92
+ }>();
93
+
94
+ if (!body.provider || !body.model) {
95
+ return c.json({ error: "provider and model required" }, 400);
96
+ }
97
+
98
+ const provider = PROVIDERS.find((p) => p.id === body.provider);
99
+ if (!provider) return c.json({ error: "Unknown provider" }, 400);
100
+
101
+ // Save API key to .env if provided (use first envKey as the primary)
102
+ if (body.apiKey && provider.envKeys.length > 0) {
103
+ writeEnvVar(provider.envKeys[0], body.apiKey);
104
+ }
105
+
106
+ // Build config
107
+ const cfg = readConfig();
108
+ const llmConfig: Record<string, unknown> = (cfg.llm as Record<string, unknown>) || {};
109
+
110
+ if (body.provider === "local") {
111
+ llmConfig.local = {
112
+ baseUrl: body.baseUrl || "http://localhost:11434",
113
+ apiType: body.apiType || "ollama",
114
+ model: body.model,
115
+ };
116
+ } else {
117
+ // Find which env key is active (API key or OAuth token)
118
+ const activeEnvKey = provider.envKeys.find((k) => !!process.env[k]) || provider.envKeys[0];
119
+ llmConfig[body.provider] = {
120
+ apiKey: activeEnvKey ? `env:${activeEnvKey}` : undefined,
121
+ model: body.model,
122
+ };
123
+ }
124
+
125
+ llmConfig.activeProvider = body.provider;
126
+ llmConfig.activeModel = body.model;
127
+
128
+ cfg.llm = llmConfig;
129
+
130
+ // Persist spending cap if provided
131
+ if (body.spendCap !== undefined) {
132
+ (cfg as Record<string, unknown>).spendCap = body.spendCap;
133
+ }
134
+
135
+ writeConfig(cfg);
136
+
137
+ return c.json({ success: true });
138
+ });
139
+
140
+ /** POST /api/config/llm/test — test LLM connection */
141
+ config.post("/llm/test", async (c) => {
142
+ const body = await c.req.json<{
143
+ provider: string;
144
+ model: string;
145
+ apiKey?: string;
146
+ baseUrl?: string;
147
+ }>();
148
+
149
+ try {
150
+ if (body.provider === "local") {
151
+ const baseUrl = body.baseUrl || "http://localhost:11434";
152
+ const res = await fetch(`${baseUrl}/api/tags`);
153
+ if (!res.ok) throw new Error(`Local server returned ${res.status}`);
154
+ return c.json({ success: true, message: "Connected to local model server" });
155
+ }
156
+
157
+ // For cloud providers, do a minimal test
158
+ const provider = PROVIDERS.find((p) => p.id === body.provider);
159
+ const apiKey = body.apiKey || (provider ? getProviderCredential(provider) : null);
160
+
161
+ if (!apiKey) {
162
+ return c.json({ success: false, message: "No API key configured" }, 400);
163
+ }
164
+
165
+ // Test with a minimal request based on provider
166
+ if (body.provider === "anthropic") {
167
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
168
+ method: "POST",
169
+ headers: {
170
+ "x-api-key": apiKey,
171
+ "anthropic-version": "2023-06-01",
172
+ "content-type": "application/json",
173
+ },
174
+ body: JSON.stringify({
175
+ model: body.model,
176
+ max_tokens: 1,
177
+ messages: [{ role: "user", content: "hi" }],
178
+ }),
179
+ });
180
+ if (!res.ok) {
181
+ const err = await res.json().catch(() => ({}));
182
+ throw new Error((err as Record<string, unknown>).error?.toString() || `HTTP ${res.status}`);
183
+ }
184
+ } else if (body.provider === "openai") {
185
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
186
+ method: "POST",
187
+ headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
188
+ body: JSON.stringify({ model: body.model, max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
189
+ });
190
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
191
+ } else if (body.provider === "gemini") {
192
+ const res = await fetch(
193
+ `https://generativelanguage.googleapis.com/v1beta/models/${body.model}:generateContent?key=${apiKey}`,
194
+ {
195
+ method: "POST",
196
+ headers: { "content-type": "application/json" },
197
+ body: JSON.stringify({ contents: [{ parts: [{ text: "hi" }] }], generationConfig: { maxOutputTokens: 1 } }),
198
+ },
199
+ );
200
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
201
+ }
202
+
203
+ return c.json({ success: true, message: "Connection verified" });
204
+ } catch (err: unknown) {
205
+ const message = err instanceof Error ? err.message : "Connection failed";
206
+ return c.json({ success: false, message }, 400);
207
+ }
208
+ });
209
+
210
+ export { config as configRoutes };
@@ -0,0 +1,186 @@
1
+ import { Hono } from "hono";
2
+ import { createPublicClient, http } from "viem";
3
+ import { base } from "viem/chains";
4
+ import { db } from "../db";
5
+ import { getEthBalance } from "../lib/publish";
6
+ import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
7
+ import { mcv2BondAbi } from "../../packages/cli/src/sdk/abi";
8
+
9
+ const MCV2_BOND = "0xc5a076cad94176c2996B32d8466Be1cE757FAa27" as const;
10
+ // Reserve token for PlotLink bonding curves (PLOT token on Base mainnet)
11
+ const RESERVE_TOKEN = "0x4F567DACBF9D15A6acBe4A47FC2Ade0719Fb63C4" as const;
12
+
13
+ const publicClient = createPublicClient({
14
+ chain: base,
15
+ transport: http(process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org"),
16
+ });
17
+
18
+ const dashboard = new Hono();
19
+
20
+ /** GET /api/dashboard — writer dashboard data */
21
+ dashboard.get("/", async (c) => {
22
+ // Get all drafts (published and unpublished)
23
+ const drafts = await db.draft.findMany({
24
+ orderBy: { createdAt: "desc" },
25
+ });
26
+
27
+ // Get wallet info
28
+ let walletInfo = null;
29
+ try {
30
+ const wallets = listAgentWallets();
31
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
32
+ if (wallet) {
33
+ const address = getBaseAddress(wallet);
34
+ if (address) {
35
+ const ethBalance = await getEthBalance(address);
36
+
37
+ // Fetch USDC balance
38
+ let usdcBalance = "0";
39
+ try {
40
+ const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
41
+ const balanceOfSig = "0x70a08231000000000000000000000000" + address.slice(2).toLowerCase();
42
+ const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
43
+ const res = await fetch(rpcUrl, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: USDC_BASE, data: balanceOfSig }, "latest"] }),
47
+ });
48
+ const data = await res.json() as { result?: string };
49
+ if (data.result && data.result !== "0x") {
50
+ usdcBalance = (Number(BigInt(data.result)) / 1e6).toFixed(2);
51
+ }
52
+ } catch { /* best effort */ }
53
+
54
+ walletInfo = {
55
+ address,
56
+ ethBalance: ethBalance.toString(),
57
+ ethFormatted: (Number(ethBalance) / 1e18).toFixed(6),
58
+ usdcBalance,
59
+ };
60
+ }
61
+ }
62
+ } catch { /* wallet not available */ }
63
+
64
+ // Published stories with cost data
65
+ const published = drafts.filter((d) => d.status === "published");
66
+ const unpublished = drafts.filter((d) => d.status !== "published");
67
+
68
+ // Compute total costs
69
+ const totalGasCostWei = published.reduce((sum, d) => {
70
+ if (d.gasCost) return sum + BigInt(d.gasCost);
71
+ return sum;
72
+ }, BigInt(0));
73
+ const totalGasCostEth = (Number(totalGasCostWei) / 1e18).toFixed(6);
74
+
75
+ // Query on-chain royalties (WETH on Base — bonding curve reserve)
76
+ let royaltiesEarned = "0";
77
+ let royaltiesClaimed = "0";
78
+ if (walletInfo?.address) {
79
+ try {
80
+ // getRoyaltyInfo returns (unclaimed, totalClaimed)
81
+ const [unclaimed, totalClaimed] = await publicClient.readContract({
82
+ address: MCV2_BOND,
83
+ abi: mcv2BondAbi,
84
+ functionName: "getRoyaltyInfo",
85
+ args: [walletInfo.address as `0x${string}`, RESERVE_TOKEN],
86
+ }) as [bigint, bigint];
87
+ // Total earned = unclaimed + previously claimed
88
+ royaltiesEarned = (Number(unclaimed + totalClaimed) / 1e18).toFixed(6);
89
+ royaltiesClaimed = (Number(totalClaimed) / 1e18).toFixed(6);
90
+ } catch { /* no royalties or contract not available */ }
91
+ }
92
+
93
+ // Fetch ETH/USD price for common-unit P&L
94
+ let ethUsdPrice = 0;
95
+ try {
96
+ const priceRes = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd");
97
+ const priceData = await priceRes.json() as { ethereum?: { usd?: number } };
98
+ ethUsdPrice = priceData.ethereum?.usd ?? 0;
99
+ } catch { /* price fetch best-effort */ }
100
+
101
+ const totalCostUsd = parseFloat(totalGasCostEth) * ethUsdPrice;
102
+
103
+ // Get PLOT/USD via existing price helper (HUNT-backed derivation)
104
+ let plotUsdPrice = 0;
105
+ try {
106
+ const { getPlotUsdPrice } = await import("../../lib/usd-price");
107
+ const price = await getPlotUsdPrice();
108
+ if (price) plotUsdPrice = price;
109
+ } catch { /* price estimation best-effort */ }
110
+
111
+ const totalRoyaltiesUsd = parseFloat(royaltiesEarned) * plotUsdPrice;
112
+ const netPnlUsd = totalRoyaltiesUsd - totalCostUsd;
113
+
114
+ // Session stats
115
+ const sessions = await db.storySession.findMany({
116
+ include: { _count: { select: { messages: true } } },
117
+ });
118
+
119
+ return c.json({
120
+ wallet: walletInfo,
121
+ stories: {
122
+ published: published.map((d) => ({
123
+ id: d.id,
124
+ title: d.title,
125
+ genre: d.genre,
126
+ status: d.status,
127
+ txHash: d.txHash,
128
+ storylineId: d.storylineId,
129
+ contentCid: d.contentCid,
130
+ gasCost: d.gasCost,
131
+ gasCostEth: d.gasCost ? (Number(BigInt(d.gasCost)) / 1e18).toFixed(6) : null,
132
+ gasCostUsd: d.gasCost && ethUsdPrice ? ((Number(BigInt(d.gasCost)) / 1e18) * ethUsdPrice).toFixed(2) : null,
133
+ createdAt: d.createdAt,
134
+ updatedAt: d.updatedAt,
135
+ })),
136
+ drafts: unpublished.map((d) => ({
137
+ id: d.id,
138
+ title: d.title,
139
+ genre: d.genre,
140
+ status: d.status,
141
+ createdAt: d.createdAt,
142
+ })),
143
+ totalPublished: published.length,
144
+ totalDrafts: unpublished.length,
145
+ },
146
+ costs: {
147
+ totalGasCostWei: totalGasCostWei.toString(),
148
+ totalGasCostEth,
149
+ totalCostUsd: totalCostUsd.toFixed(2),
150
+ ethUsdPrice,
151
+ storiesPublished: published.length,
152
+ },
153
+ royalties: {
154
+ earned: royaltiesEarned,
155
+ claimed: royaltiesClaimed,
156
+ // unclaimed = earned - claimed (already correct since earned = unclaimed + claimed)
157
+ unclaimed: (parseFloat(royaltiesEarned) - parseFloat(royaltiesClaimed)).toFixed(6),
158
+ token: "PLOT",
159
+ },
160
+ pnl: {
161
+ totalCostsEth: totalGasCostEth,
162
+ totalCostsUsd: totalCostUsd.toFixed(2),
163
+ totalRoyaltiesPlot: royaltiesEarned,
164
+ totalRoyaltiesUsd: totalRoyaltiesUsd.toFixed(2),
165
+ netPnlUsd: netPnlUsd.toFixed(2),
166
+ plotUsdPrice: plotUsdPrice.toFixed(4),
167
+ },
168
+ sessions: {
169
+ total: sessions.length,
170
+ totalMessages: sessions.reduce((sum, s) => sum + s._count.messages, 0),
171
+ },
172
+ });
173
+ });
174
+
175
+ /** DELETE /api/dashboard/drafts/:id — delete a draft */
176
+ dashboard.delete("/drafts/:id", async (c) => {
177
+ const id = c.req.param("id");
178
+ try {
179
+ await db.draft.delete({ where: { id } });
180
+ return c.json({ success: true });
181
+ } catch {
182
+ return c.json({ error: "Draft not found" }, 404);
183
+ }
184
+ });
185
+
186
+ export { dashboard as dashboardRoutes };
@@ -0,0 +1,150 @@
1
+ import { Hono } from "hono";
2
+ import { randomBytes, createHash } from "crypto";
3
+ import fs from "fs";
4
+ import { ENV_FILE } from "../lib/paths";
5
+
6
+ const envPath = ENV_FILE;
7
+
8
+ const oauth = new Hono();
9
+
10
+ // OAuth state store (in-memory, keyed by state param)
11
+ const pendingFlows = new Map<string, { provider: string; codeVerifier: string; status: "pending" | "complete"; token?: string }>();
12
+
13
+ const OAUTH_CONFIGS: Record<string, { authUrl: string; tokenUrl: string; clientId: string; envKey: string }> = {
14
+ anthropic: {
15
+ authUrl: "https://console.anthropic.com/oauth/authorize",
16
+ tokenUrl: "https://console.anthropic.com/oauth/token",
17
+ clientId: "plotlink-ows-local",
18
+ envKey: "ANTHROPIC_OAUTH_TOKEN",
19
+ },
20
+ openai: {
21
+ authUrl: "https://platform.openai.com/oauth/authorize",
22
+ tokenUrl: "https://platform.openai.com/oauth/token",
23
+ clientId: "plotlink-ows-local",
24
+ envKey: "OPENAI_OAUTH_TOKEN",
25
+ },
26
+ };
27
+
28
+ function writeEnvVar(key: string, value: string) {
29
+ if (fs.existsSync(envPath)) {
30
+ const content = fs.readFileSync(envPath, "utf-8");
31
+ const regex = new RegExp(`^${key}=.*$`, "m");
32
+ if (regex.test(content)) {
33
+ fs.writeFileSync(envPath, content.replace(regex, `${key}=${value}`));
34
+ } else {
35
+ fs.appendFileSync(envPath, `\n${key}=${value}\n`);
36
+ }
37
+ } else {
38
+ fs.writeFileSync(envPath, `${key}=${value}\n`);
39
+ }
40
+ process.env[key] = value;
41
+ }
42
+
43
+ /** GET /api/oauth/:provider/start — initiate OAuth PKCE flow */
44
+ oauth.get("/:provider/start", (c) => {
45
+ const provider = c.req.param("provider");
46
+ const config = OAUTH_CONFIGS[provider];
47
+ if (!config) return c.json({ error: "Unsupported OAuth provider" }, 400);
48
+
49
+ const state = randomBytes(16).toString("hex");
50
+ const codeVerifier = randomBytes(32).toString("base64url");
51
+
52
+ pendingFlows.set(state, { provider, codeVerifier, status: "pending" });
53
+
54
+ // Compute S256 code_challenge from verifier
55
+ const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
56
+
57
+ // Build authorization URL with PKCE
58
+ const params = new URLSearchParams({
59
+ client_id: config.clientId,
60
+ response_type: "code",
61
+ redirect_uri: "http://localhost:7777/api/oauth/callback",
62
+ state,
63
+ code_challenge: codeChallenge,
64
+ code_challenge_method: "S256",
65
+ scope: "api",
66
+ });
67
+
68
+ const authUrl = `${config.authUrl}?${params}`;
69
+
70
+ return c.json({ authUrl, state });
71
+ });
72
+
73
+ /** GET /api/oauth/callback — OAuth redirect handler */
74
+ oauth.get("/callback", async (c) => {
75
+ const code = c.req.query("code");
76
+ const state = c.req.query("state");
77
+ const error = c.req.query("error");
78
+
79
+ if (error) {
80
+ return c.html(`<html><body><h2>OAuth Error</h2><p>${error}</p><script>window.close()</script></body></html>`);
81
+ }
82
+
83
+ if (!code || !state) {
84
+ return c.html(`<html><body><h2>Missing parameters</h2><script>window.close()</script></body></html>`);
85
+ }
86
+
87
+ const flow = pendingFlows.get(state);
88
+ if (!flow) {
89
+ return c.html(`<html><body><h2>Invalid state</h2><script>window.close()</script></body></html>`);
90
+ }
91
+
92
+ const config = OAUTH_CONFIGS[flow.provider];
93
+ if (!config) {
94
+ return c.html(`<html><body><h2>Unknown provider</h2><script>window.close()</script></body></html>`);
95
+ }
96
+
97
+ try {
98
+ // Exchange code for token
99
+ const res = await fetch(config.tokenUrl, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
102
+ body: new URLSearchParams({
103
+ grant_type: "authorization_code",
104
+ code,
105
+ redirect_uri: "http://localhost:7777/api/oauth/callback",
106
+ client_id: config.clientId,
107
+ code_verifier: flow.codeVerifier,
108
+ }),
109
+ });
110
+
111
+ const data = await res.json() as Record<string, unknown>;
112
+
113
+ if (!res.ok) {
114
+ throw new Error((data.error_description || data.error || "Token exchange failed") as string);
115
+ }
116
+
117
+ const accessToken = data.access_token as string;
118
+ writeEnvVar(config.envKey, accessToken);
119
+ flow.status = "complete";
120
+ flow.token = accessToken;
121
+
122
+ return c.html(`<html><body><h2>Connected!</h2><p>You can close this window.</p><script>window.close()</script></body></html>`);
123
+ } catch (err: unknown) {
124
+ const message = err instanceof Error ? err.message : "Token exchange failed";
125
+ return c.html(`<html><body><h2>Error</h2><p>${message}</p><script>window.close()</script></body></html>`);
126
+ }
127
+ });
128
+
129
+ /** GET /api/oauth/:provider/status — poll for OAuth completion */
130
+ oauth.get("/:provider/status", (c) => {
131
+ const provider = c.req.param("provider");
132
+ const config = OAUTH_CONFIGS[provider];
133
+ if (!config) return c.json({ error: "Unsupported provider" }, 400);
134
+
135
+ // Check if token is already in env
136
+ if (process.env[config.envKey]) {
137
+ return c.json({ complete: true });
138
+ }
139
+
140
+ // Check pending flows
141
+ for (const [, flow] of pendingFlows) {
142
+ if (flow.provider === provider && flow.status === "complete") {
143
+ return c.json({ complete: true });
144
+ }
145
+ }
146
+
147
+ return c.json({ complete: false });
148
+ });
149
+
150
+ export { oauth as oauthRoutes };
@@ -0,0 +1,112 @@
1
+ import { Hono } from "hono";
2
+ import { streamSSE } from "hono/streaming";
3
+ import { db } from "../db";
4
+ import { publishStoryline, getEthBalance, estimatePublishCost } from "../lib/publish";
5
+ import { keccak256, toBytes } from "viem";
6
+ import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
7
+
8
+ const publish = new Hono();
9
+
10
+ /** GET /api/publish/preflight — check if publishing is possible */
11
+ publish.get("/preflight", async (c) => {
12
+ try {
13
+ // Check wallet
14
+ const wallets = listAgentWallets();
15
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
16
+ if (!wallet) {
17
+ return c.json({ ready: false, error: "No OWS wallet found" });
18
+ }
19
+
20
+ const address = getBaseAddress(wallet);
21
+ if (!address) {
22
+ return c.json({ ready: false, error: "No EVM address on wallet" });
23
+ }
24
+
25
+ // Check ETH balance against real estimated cost
26
+ const balance = await getEthBalance(address);
27
+ let totalCost: bigint | null = null;
28
+ let creationFee = BigInt(0);
29
+ let estimationFailed = false;
30
+ try {
31
+ const dummyCid = "QmDummy";
32
+ const dummyHash = keccak256(toBytes("estimation"));
33
+ const estimate = await estimatePublishCost(address, "Test", dummyCid, dummyHash);
34
+ totalCost = estimate.totalCost;
35
+ creationFee = estimate.creationFee;
36
+ } catch {
37
+ estimationFailed = true;
38
+ }
39
+ // Fail closed: if estimation fails, block publishing
40
+ const requiredBalance = totalCost ?? BigInt(0);
41
+ const hasEnoughEth = !estimationFailed && totalCost !== null && balance >= requiredBalance;
42
+
43
+ // Check Filebase config
44
+ const hasFilebase = !!(process.env.FILEBASE_ACCESS_KEY && process.env.FILEBASE_SECRET_KEY);
45
+
46
+ return c.json({
47
+ ready: hasEnoughEth && hasFilebase,
48
+ address,
49
+ ethBalance: balance.toString(),
50
+ creationFee: creationFee.toString(),
51
+ requiredBalance: requiredBalance.toString(),
52
+ hasEnoughEth,
53
+ hasFilebase,
54
+ estimationFailed,
55
+ error: estimationFailed
56
+ ? "Could not estimate publish cost — check RPC and contract config"
57
+ : !hasEnoughEth
58
+ ? `Insufficient ETH. Need ~${(Number(requiredBalance) / 1e18).toFixed(6)} ETH (creation fee + gas)`
59
+ : !hasFilebase ? "Filebase not configured" : null,
60
+ });
61
+ } catch (err: unknown) {
62
+ const message = err instanceof Error ? err.message : "Preflight check failed";
63
+ return c.json({ ready: false, error: message });
64
+ }
65
+ });
66
+
67
+ /** POST /api/publish/:draftId — publish a draft on-chain (streams progress) */
68
+ publish.post("/:draftId", async (c) => {
69
+ const draftId = c.req.param("draftId");
70
+
71
+ const draft = await db.draft.findUnique({ where: { id: draftId } });
72
+ if (!draft) return c.json({ error: "Draft not found" }, 404);
73
+ if (draft.status === "published") return c.json({ error: "Already published" }, 409);
74
+
75
+ // Get wallet
76
+ const wallets = listAgentWallets();
77
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
78
+ if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
79
+
80
+ return streamSSE(c, async (stream) => {
81
+ try {
82
+ const result = await publishStoryline(
83
+ wallet.name,
84
+ draft.title,
85
+ draft.content,
86
+ draft.genre || undefined,
87
+ async (progress) => {
88
+ await stream.writeSSE({ data: JSON.stringify(progress) });
89
+ },
90
+ );
91
+
92
+ // Only mark published after tx confirmed (publishStoryline waits for confirmation)
93
+ await db.draft.update({
94
+ where: { id: draftId },
95
+ data: {
96
+ status: "published",
97
+ txHash: result.txHash,
98
+ storylineId: result.storylineId,
99
+ contentCid: result.contentCid,
100
+ gasCost: result.gasCost,
101
+ },
102
+ });
103
+ } catch (err: unknown) {
104
+ const message = err instanceof Error ? err.message : "Publish failed";
105
+ await stream.writeSSE({
106
+ data: JSON.stringify({ step: "error", message, error: message }),
107
+ });
108
+ }
109
+ });
110
+ });
111
+
112
+ export { publish as publishRoutes };