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.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/app/db.ts +8 -0
- package/app/lib/llm-client.ts +265 -0
- package/app/lib/paths.ts +11 -0
- package/app/lib/publish.ts +204 -0
- package/app/lib/writer-prompt.ts +44 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/client.js +5 -0
- package/app/node_modules/.prisma/local-client/default.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/default.js +5 -0
- package/app/node_modules/.prisma/local-client/edge.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/edge.js +184 -0
- package/app/node_modules/.prisma/local-client/index-browser.js +173 -0
- package/app/node_modules/.prisma/local-client/index.d.ts +3304 -0
- package/app/node_modules/.prisma/local-client/index.js +207 -0
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +183 -0
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +2 -0
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +35 -0
- package/app/node_modules/.prisma/local-client/runtime/edge.js +35 -0
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +370 -0
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +17 -0
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +3982 -0
- package/app/node_modules/.prisma/local-client/runtime/library.js +147 -0
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +84 -0
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +85 -0
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +38 -0
- package/app/node_modules/.prisma/local-client/schema.prisma +21 -0
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +5 -0
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +5 -0
- package/app/node_modules/.prisma/local-client/wasm.d.ts +1 -0
- package/app/node_modules/.prisma/local-client/wasm.js +191 -0
- package/app/prisma/schema.prisma +57 -0
- package/app/routes/auth.ts +173 -0
- package/app/routes/chat.ts +135 -0
- package/app/routes/config.ts +210 -0
- package/app/routes/dashboard.ts +186 -0
- package/app/routes/oauth.ts +150 -0
- package/app/routes/publish.ts +112 -0
- package/app/routes/wallet.ts +99 -0
- package/app/server.ts +154 -0
- package/app/vite.config.ts +19 -0
- package/app/web/App.tsx +102 -0
- package/app/web/components/Chat.tsx +272 -0
- package/app/web/components/Dashboard.tsx +222 -0
- package/app/web/components/LLMSetup.tsx +291 -0
- package/app/web/components/Layout.tsx +235 -0
- package/app/web/components/Login.tsx +62 -0
- package/app/web/components/Publish.tsx +245 -0
- package/app/web/components/Settings.tsx +175 -0
- package/app/web/components/Setup.tsx +84 -0
- package/app/web/components/WalletCard.tsx +117 -0
- package/app/web/dist/assets/index-C9kXlYO_.css +2 -0
- package/app/web/dist/assets/index-CJiiaLHs.js +9 -0
- package/app/web/dist/index.html +16 -0
- package/app/web/index.html +15 -0
- package/app/web/main.tsx +10 -0
- package/app/web/plotlink-logo.svg +5 -0
- package/app/web/styles.css +51 -0
- package/bin/plotlink-ows.js +394 -0
- package/lib/ows/index.ts +3 -0
- package/lib/ows/policy.ts +68 -0
- package/lib/ows/types.ts +14 -0
- package/lib/ows/wallet.ts +70 -0
- package/package.json +79 -0
- package/packages/cli/node_modules/commander/LICENSE +22 -0
- package/packages/cli/node_modules/commander/Readme.md +1149 -0
- package/packages/cli/node_modules/commander/esm.mjs +16 -0
- package/packages/cli/node_modules/commander/index.js +24 -0
- package/packages/cli/node_modules/commander/lib/argument.js +149 -0
- package/packages/cli/node_modules/commander/lib/command.js +2662 -0
- package/packages/cli/node_modules/commander/lib/error.js +39 -0
- package/packages/cli/node_modules/commander/lib/help.js +709 -0
- package/packages/cli/node_modules/commander/lib/option.js +367 -0
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +101 -0
- package/packages/cli/node_modules/commander/package-support.json +16 -0
- package/packages/cli/node_modules/commander/package.json +82 -0
- package/packages/cli/node_modules/commander/typings/esm.d.mts +3 -0
- package/packages/cli/node_modules/commander/typings/index.d.ts +1045 -0
- package/packages/cli/node_modules/resolve-from/index.d.ts +31 -0
- package/packages/cli/node_modules/resolve-from/index.js +47 -0
- package/packages/cli/node_modules/resolve-from/license +9 -0
- package/packages/cli/node_modules/resolve-from/package.json +36 -0
- package/packages/cli/node_modules/resolve-from/readme.md +72 -0
- package/packages/cli/node_modules/tsup/LICENSE +21 -0
- package/packages/cli/node_modules/tsup/README.md +75 -0
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +13 -0
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +9 -0
- package/packages/cli/node_modules/tsup/assets/package.json +3 -0
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +153 -0
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +42 -0
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +6 -0
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +352 -0
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +203 -0
- package/packages/cli/node_modules/tsup/dist/cli-default.js +12 -0
- package/packages/cli/node_modules/tsup/dist/cli-main.js +8 -0
- package/packages/cli/node_modules/tsup/dist/cli-node.js +14 -0
- package/packages/cli/node_modules/tsup/dist/index.d.ts +511 -0
- package/packages/cli/node_modules/tsup/dist/index.js +1711 -0
- package/packages/cli/node_modules/tsup/dist/rollup.js +6949 -0
- package/packages/cli/node_modules/tsup/package.json +99 -0
- package/packages/cli/node_modules/tsup/schema.json +362 -0
- package/packages/cli/package.json +35 -0
- package/packages/cli/src/commands/agent-register.ts +77 -0
- package/packages/cli/src/commands/chain.ts +29 -0
- package/packages/cli/src/commands/claim.ts +70 -0
- package/packages/cli/src/commands/create.ts +34 -0
- package/packages/cli/src/commands/status.ts +201 -0
- package/packages/cli/src/config.ts +103 -0
- package/packages/cli/src/index.ts +21 -0
- package/packages/cli/src/sdk/abi.ts +222 -0
- package/packages/cli/src/sdk/client.ts +713 -0
- package/packages/cli/src/sdk/constants.ts +56 -0
- package/packages/cli/src/sdk/index.ts +46 -0
- package/packages/cli/src/sdk/ipfs.ts +88 -0
- package/packages/cli/src/sdk.ts +36 -0
- package/packages/cli/tsconfig.json +20 -0
- package/packages/cli/tsup.config.ts +14 -0
- package/public/.well-known/farcaster.json +38 -0
- package/public/basescan-icon.svg +4 -0
- package/public/embed-image.png +0 -0
- package/public/favicon.png +0 -0
- package/public/hunt-token.svg +11 -0
- package/public/icon-192.png +0 -0
- package/public/icon.png +0 -0
- package/public/manifest.json +26 -0
- package/public/mc-icon-light.svg +12 -0
- package/public/og-image.png +0 -0
- package/public/plotlink-logo-symbol.svg +5 -0
- package/public/plotlink-logo.svg +5 -0
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/public/splash.png +0 -0
- package/public/wide-banner.png +0 -0
- package/scripts/backfill-trade-prices.ts +97 -0
- package/scripts/backfill-usd-rates.ts +220 -0
- package/scripts/e2e-verify.ts +1100 -0
- package/scripts/ows-smoke-test.ts +37 -0
- 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 };
|