leak-cli 2026.2.15-beta.1 → 2026.2.16
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 +6 -3
- package/package.json +3 -2
- package/scripts/config.js +12 -1
- package/scripts/leak.js +13 -2
- package/src/chain_meta.js +55 -0
- package/src/index.js +390 -127
package/README.md
CHANGED
|
@@ -255,6 +255,7 @@ npm run leak -- --file ./song.mp3 --pay-to 0x... --price 1 --window 1h --public
|
|
|
255
255
|
```
|
|
256
256
|
|
|
257
257
|
When a local image path is used for `--og-image-url`, leak serves it from `/og-image` and points OG/Twitter metadata at that endpoint.
|
|
258
|
+
Without `--og-image-url`, leak serves a generated raster OG card from `/og.png` (and keeps `/og.svg` for debug/backward compatibility).
|
|
258
259
|
|
|
259
260
|
This mirrors the behavior of the original Python scaffold implementation:
|
|
260
261
|
|
|
@@ -422,14 +423,15 @@ curl -L -o out.bin "http://localhost:4021/download?token=..."
|
|
|
422
423
|
|
|
423
424
|
- `GET /` promo HTML page with OG/Twitter tags
|
|
424
425
|
- `200` while sale is active
|
|
425
|
-
- `
|
|
426
|
+
- `200` once sale has ended (ended state is shown in page content/metadata)
|
|
426
427
|
- `GET|HEAD /.well-known/skills/index.json` RFC skill discovery index
|
|
427
428
|
- `GET|HEAD /.well-known/skills/leak/SKILL.md` RFC skill metadata markdown
|
|
428
429
|
- `GET|HEAD /.well-known/skills/leak/resource.json` RFC sale/resource metadata (`200` live, `410` ended)
|
|
429
430
|
- `GET /.well-known/leak` legacy discovery endpoint (backward-compatible)
|
|
430
431
|
- `GET /info` machine-readable JSON status (compat endpoint)
|
|
431
|
-
- `GET /og-image` configured OG image file (when using local `--og-image-url` path)
|
|
432
|
-
- `GET /og.
|
|
432
|
+
- `GET|HEAD /og-image` configured OG image file (when using local `--og-image-url` path)
|
|
433
|
+
- `GET|HEAD /og.png` generated default OG image (used when `--og-image-url` is not set)
|
|
434
|
+
- `GET|HEAD /og.svg` debug/backward-compatible OG SVG
|
|
433
435
|
- `GET /health` free health check
|
|
434
436
|
- `GET /download` x402-protected download endpoint
|
|
435
437
|
- active sale: normal x402/token flow
|
|
@@ -440,6 +442,7 @@ curl -L -o out.bin "http://localhost:4021/download?token=..."
|
|
|
440
442
|
## Troubleshooting
|
|
441
443
|
|
|
442
444
|
- **`Invalid seller payout address`** → set `--pay-to` / `SELLER_PAY_TO` to a valid Ethereum address (`0x` + 40 hex chars).
|
|
445
|
+
- **Farcaster/Warpcast preview missing OG image** → prefer PNG/JPG (`--og-image-url` or default `/og.png`), ensure OG URLs are absolute `https://` (set `PUBLIC_BASE_URL` if needed), and re-share with a fresh URL variant (example: `/?v=2`) to bypass crawler cache.
|
|
443
446
|
|
|
444
447
|
---
|
|
445
448
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leak-cli",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.16",
|
|
4
4
|
"description": "Self-hosted pop-up stores for creators -- with agent-friendly automation built in",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"check:version-sync": "node scripts/check_version_sync.js",
|
|
29
29
|
"check:changelog-version": "node scripts/check_changelog_version.js",
|
|
30
30
|
"check:no-local-paths": "node scripts/check_no_local_paths.js",
|
|
31
|
-
"check:syntax": "node --check src/index.js && node --check scripts/cli.js && node --check scripts/leak.js && node --check scripts/buy.js && node --check scripts/config.js",
|
|
31
|
+
"check:syntax": "node --check src/index.js && node --check src/chain_meta.js && node --check scripts/cli.js && node --check scripts/leak.js && node --check scripts/buy.js && node --check scripts/config.js",
|
|
32
32
|
"check:smoke": "node scripts/cli.js --help && node scripts/leak.js --help && node scripts/buy.js --help && node scripts/config.js --help",
|
|
33
33
|
"check:release": "npm run check:version-sync && npm run check:syntax && npm run check:smoke && npm run check:no-local-paths && npm pack --dry-run --cache ./.npm-cache",
|
|
34
34
|
"release:beta": "npm publish --tag beta --provenance",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"license": "ISC",
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@coinbase/cdp-sdk": "^1.12.0",
|
|
54
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
54
55
|
"@x402/core": "^2.3.0",
|
|
55
56
|
"@x402/evm": "^2.3.0",
|
|
56
57
|
"@x402/express": "^2.3.0",
|
package/scripts/config.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
readConfig,
|
|
15
15
|
writeConfig,
|
|
16
16
|
} from "./config_store.js";
|
|
17
|
+
import { resolveSupportedChain } from "../src/chain_meta.js";
|
|
17
18
|
|
|
18
19
|
const ALLOWED_FACILITATOR_MODES = new Set(["testnet", "cdp_mainnet"]);
|
|
19
20
|
const ALLOWED_CONFIRMATION_POLICIES = new Set(["confirmed", "optimistic"]);
|
|
@@ -217,11 +218,21 @@ async function runWizard({ writeEnv }) {
|
|
|
217
218
|
sellerPayTo = await askWithDefault(rl, "SELLER_PAY_TO (seller payout address)", sellerPayTo);
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
|
|
221
|
+
let chainIdInput = await askWithDefault(
|
|
221
222
|
rl,
|
|
222
223
|
"CHAIN_ID",
|
|
223
224
|
existing.chainId || "eip155:84532",
|
|
224
225
|
);
|
|
226
|
+
let chainId;
|
|
227
|
+
while (true) {
|
|
228
|
+
try {
|
|
229
|
+
chainId = resolveSupportedChain(chainIdInput).caip2;
|
|
230
|
+
break;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error(err.message || String(err));
|
|
233
|
+
chainIdInput = await askWithDefault(rl, "CHAIN_ID", chainIdInput || "eip155:84532");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
225
236
|
|
|
226
237
|
let facilitatorMode = await askWithDefault(
|
|
227
238
|
rl,
|
package/scripts/leak.js
CHANGED
|
@@ -8,6 +8,7 @@ import { stdin as input, stdout as output } from "node:process";
|
|
|
8
8
|
import { spawn, spawnSync } from "node:child_process";
|
|
9
9
|
import { isAddress } from "viem";
|
|
10
10
|
import { defaultFacilitatorUrlForMode, readConfig } from "./config_store.js";
|
|
11
|
+
import { resolveSupportedChain } from "../src/chain_meta.js";
|
|
11
12
|
|
|
12
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
13
14
|
const __dirname = path.dirname(__filename);
|
|
@@ -330,7 +331,17 @@ async function main() {
|
|
|
330
331
|
process.exit(1);
|
|
331
332
|
}
|
|
332
333
|
|
|
333
|
-
const
|
|
334
|
+
const networkInput = args.network || process.env.CHAIN_ID || configDefaults.chainId || "eip155:84532";
|
|
335
|
+
let network;
|
|
336
|
+
let networkName;
|
|
337
|
+
try {
|
|
338
|
+
const networkMeta = resolveSupportedChain(networkInput);
|
|
339
|
+
network = networkMeta.caip2;
|
|
340
|
+
networkName = networkMeta.name;
|
|
341
|
+
} catch (err) {
|
|
342
|
+
console.error(err.message || String(err));
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
334
345
|
const port = Number(args.port || process.env.PORT || configDefaults.port || 4021);
|
|
335
346
|
const facilitatorMode = (
|
|
336
347
|
process.env.FACILITATOR_MODE || configDefaults.facilitatorMode || "testnet"
|
|
@@ -419,7 +430,7 @@ async function main() {
|
|
|
419
430
|
console.log(`- price: ${prompted.price} USDC`);
|
|
420
431
|
console.log(`- window: ${prompted.windowSeconds}s`);
|
|
421
432
|
console.log(`- to: ${payTo}`);
|
|
422
|
-
console.log(`- net: ${network}`);
|
|
433
|
+
console.log(`- net: ${network} (${networkName})`);
|
|
423
434
|
console.log(`- mode: ${confirmationPolicy}`);
|
|
424
435
|
console.log(`- facilitator_mode: ${facilitatorMode}`);
|
|
425
436
|
console.log(`- facilitator_url: ${facilitatorUrl}`);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { base, baseSepolia } from "viem/chains";
|
|
2
|
+
import { extractChain } from "viem/chains/utils";
|
|
3
|
+
|
|
4
|
+
const SUPPORTED_CHAINS = [base, baseSepolia];
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_CHAIN_SUMMARY = [
|
|
7
|
+
`${base.id} (${base.name})`,
|
|
8
|
+
`${baseSepolia.id} (${baseSepolia.name})`,
|
|
9
|
+
].join(", ");
|
|
10
|
+
|
|
11
|
+
function invalidFormatMessage(raw) {
|
|
12
|
+
const value = String(raw ?? "").trim() || "<empty>";
|
|
13
|
+
return `Invalid chain identifier '${value}'. Expected eip155:<number>. Supported values: ${SUPPORTED_CHAIN_SUMMARY}.`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function unsupportedChainMessage(caip2) {
|
|
17
|
+
return `Unsupported chain '${caip2}'. Supported values: ${SUPPORTED_CHAIN_SUMMARY}.`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseCaip2Eip155(chainIdString) {
|
|
21
|
+
const value = String(chainIdString ?? "").trim();
|
|
22
|
+
const match = value.match(/^eip155:(\d+)$/);
|
|
23
|
+
if (!match) {
|
|
24
|
+
throw new Error(invalidFormatMessage(chainIdString));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const id = Number(match[1]);
|
|
28
|
+
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
29
|
+
throw new Error(invalidFormatMessage(chainIdString));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
id,
|
|
34
|
+
caip2: `eip155:${id}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveSupportedChain(chainIdString) {
|
|
39
|
+
const parsed = parseCaip2Eip155(chainIdString);
|
|
40
|
+
const chain = extractChain({ chains: SUPPORTED_CHAINS, id: parsed.id });
|
|
41
|
+
if (!chain) {
|
|
42
|
+
throw new Error(unsupportedChainMessage(parsed.caip2));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
caip2: parsed.caip2,
|
|
47
|
+
id: parsed.id,
|
|
48
|
+
name: chain.name,
|
|
49
|
+
testnet: Boolean(chain.testnet),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatChainDisplayName(chainIdString) {
|
|
54
|
+
return resolveSupportedChain(chainIdString).name;
|
|
55
|
+
}
|
package/src/index.js
CHANGED
|
@@ -4,11 +4,13 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
7
8
|
|
|
8
9
|
import { x402ResourceServer } from "@x402/core/server";
|
|
9
10
|
import { x402HTTPResourceServer, HTTPFacilitatorClient } from "@x402/core/http";
|
|
10
11
|
import { ExactEvmScheme } from "@x402/evm/exact/server";
|
|
11
12
|
import { isAddress } from "viem";
|
|
13
|
+
import { resolveSupportedChain } from "./chain_meta.js";
|
|
12
14
|
|
|
13
15
|
dotenv.config();
|
|
14
16
|
|
|
@@ -73,18 +75,27 @@ const FACILITATOR_MODE = (process.env.FACILITATOR_MODE || "testnet").trim();
|
|
|
73
75
|
const CDP_API_KEY_ID = (process.env.CDP_API_KEY_ID || "").trim();
|
|
74
76
|
const CDP_API_KEY_SECRET = (process.env.CDP_API_KEY_SECRET || "").trim();
|
|
75
77
|
const DEFAULT_TESTNET_FACILITATOR_URL = "https://x402.org/facilitator";
|
|
76
|
-
const DEFAULT_CDP_MAINNET_FACILITATOR_URL =
|
|
78
|
+
const DEFAULT_CDP_MAINNET_FACILITATOR_URL =
|
|
79
|
+
"https://api.cdp.coinbase.com/platform/v2/x402";
|
|
77
80
|
const FACILITATOR_URL = (
|
|
78
81
|
process.env.FACILITATOR_URL ||
|
|
79
|
-
(FACILITATOR_MODE === "cdp_mainnet"
|
|
82
|
+
(FACILITATOR_MODE === "cdp_mainnet"
|
|
83
|
+
? DEFAULT_CDP_MAINNET_FACILITATOR_URL
|
|
84
|
+
: DEFAULT_TESTNET_FACILITATOR_URL)
|
|
85
|
+
).trim();
|
|
86
|
+
const SELLER_PAY_TO = String(
|
|
87
|
+
process.env.SELLER_PAY_TO || process.env.PAY_TO || "",
|
|
80
88
|
).trim();
|
|
81
|
-
const SELLER_PAY_TO = String(process.env.SELLER_PAY_TO || process.env.PAY_TO || "").trim();
|
|
82
89
|
const PRICE_USD = process.env.PRICE_USD || "1.00";
|
|
83
|
-
const
|
|
90
|
+
const RAW_CHAIN_ID =
|
|
91
|
+
process.env.CHAIN_ID || process.env.NETWORK || "eip155:84532";
|
|
84
92
|
const ARTIFACT_PATH = process.env.ARTIFACT_PATH || process.env.PROTECTED_FILE;
|
|
85
93
|
const WINDOW_SECONDS = Number(process.env.WINDOW_SECONDS || 3600);
|
|
86
94
|
const MAX_GRANTS = parsePositiveInt(process.env.MAX_GRANTS, 10000);
|
|
87
|
-
const GRANT_SWEEP_SECONDS = parsePositiveInt(
|
|
95
|
+
const GRANT_SWEEP_SECONDS = parsePositiveInt(
|
|
96
|
+
process.env.GRANT_SWEEP_SECONDS,
|
|
97
|
+
60,
|
|
98
|
+
);
|
|
88
99
|
|
|
89
100
|
const CONFIRMATION_POLICY = process.env.CONFIRMATION_POLICY || "confirmed"; // optimistic|confirmed
|
|
90
101
|
const CONFIRMATIONS_REQUIRED = Number(process.env.CONFIRMATIONS_REQUIRED || 1);
|
|
@@ -97,10 +108,16 @@ const OG_IMAGE_URL = (process.env.OG_IMAGE_URL || "").trim();
|
|
|
97
108
|
const OG_IMAGE_PATH_RAW = (process.env.OG_IMAGE_PATH || "").trim();
|
|
98
109
|
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || "").trim();
|
|
99
110
|
const OG_IMAGE_PATH = OG_IMAGE_PATH_RAW
|
|
100
|
-
?
|
|
111
|
+
? path.isAbsolute(OG_IMAGE_PATH_RAW)
|
|
112
|
+
? OG_IMAGE_PATH_RAW
|
|
113
|
+
: path.join(__dirname, "..", OG_IMAGE_PATH_RAW)
|
|
101
114
|
: "";
|
|
115
|
+
const OG_IMAGE_CACHE_CONTROL = "public, max-age=60";
|
|
116
|
+
const OG_IMAGE_WIDTH = 1200;
|
|
117
|
+
const OG_IMAGE_HEIGHT = 630;
|
|
102
118
|
const SKILL_NAME = "leak";
|
|
103
|
-
const SKILL_DESCRIPTION =
|
|
119
|
+
const SKILL_DESCRIPTION =
|
|
120
|
+
"Sell or buy x402-gated digital content using the leak CLI tool";
|
|
104
121
|
const SKILL_SOURCE = "clawhub";
|
|
105
122
|
const SKILL_INSTALL_COMMAND = "clawhub install leak";
|
|
106
123
|
const WELL_KNOWN_CACHE_CONTROL = "public, max-age=60";
|
|
@@ -108,24 +125,53 @@ const LEGACY_DISCOVERY_DEPRECATION =
|
|
|
108
125
|
"Deprecated endpoint; use /.well-known/skills/index.json for RFC-compatible discovery.";
|
|
109
126
|
|
|
110
127
|
const SALE_START_TS = parsePositiveInt(process.env.SALE_START_TS, now());
|
|
111
|
-
const SALE_END_TS = parsePositiveInt(
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
const SALE_END_TS = parsePositiveInt(
|
|
129
|
+
process.env.SALE_END_TS,
|
|
130
|
+
SALE_START_TS + WINDOW_SECONDS,
|
|
131
|
+
);
|
|
132
|
+
const ENDED_WINDOW_SECONDS = parseNonNegativeInt(
|
|
133
|
+
process.env.ENDED_WINDOW_SECONDS,
|
|
134
|
+
0,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
let CHAIN_META;
|
|
138
|
+
try {
|
|
139
|
+
CHAIN_META = resolveSupportedChain(RAW_CHAIN_ID);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(err?.message || String(err));
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const CHAIN_ID = CHAIN_META.caip2;
|
|
146
|
+
const CHAIN_NAME = CHAIN_META.name;
|
|
147
|
+
const CHAIN_NUMERIC_ID = CHAIN_META.id;
|
|
148
|
+
const IS_BASE_MAINNET = CHAIN_NUMERIC_ID === 8453;
|
|
114
149
|
|
|
115
150
|
if (!new Set(["testnet", "cdp_mainnet"]).has(FACILITATOR_MODE)) {
|
|
116
|
-
console.error(
|
|
151
|
+
console.error(
|
|
152
|
+
"Invalid FACILITATOR_MODE. Supported values: testnet, cdp_mainnet",
|
|
153
|
+
);
|
|
117
154
|
process.exit(1);
|
|
118
155
|
}
|
|
119
156
|
|
|
120
157
|
if (IS_BASE_MAINNET && FACILITATOR_MODE !== "cdp_mainnet") {
|
|
121
|
-
console.error(
|
|
122
|
-
|
|
158
|
+
console.error(
|
|
159
|
+
"Invalid config: CHAIN_ID=eip155:8453 requires FACILITATOR_MODE=cdp_mainnet.",
|
|
160
|
+
);
|
|
161
|
+
console.error(
|
|
162
|
+
"Set FACILITATOR_MODE=cdp_mainnet and configure CDP_API_KEY_ID/CDP_API_KEY_SECRET.",
|
|
163
|
+
);
|
|
123
164
|
process.exit(1);
|
|
124
165
|
}
|
|
125
166
|
|
|
126
|
-
if (
|
|
167
|
+
if (
|
|
168
|
+
FACILITATOR_MODE === "cdp_mainnet" &&
|
|
169
|
+
(!CDP_API_KEY_ID || !CDP_API_KEY_SECRET)
|
|
170
|
+
) {
|
|
127
171
|
console.error("Missing CDP credentials for FACILITATOR_MODE=cdp_mainnet.");
|
|
128
|
-
console.error(
|
|
172
|
+
console.error(
|
|
173
|
+
"Set CDP_API_KEY_ID and CDP_API_KEY_SECRET in your environment.",
|
|
174
|
+
);
|
|
129
175
|
process.exit(1);
|
|
130
176
|
}
|
|
131
177
|
|
|
@@ -144,7 +190,9 @@ if (!ARTIFACT_PATH) {
|
|
|
144
190
|
}
|
|
145
191
|
|
|
146
192
|
function absArtifactPath() {
|
|
147
|
-
return path.isAbsolute(ARTIFACT_PATH)
|
|
193
|
+
return path.isAbsolute(ARTIFACT_PATH)
|
|
194
|
+
? ARTIFACT_PATH
|
|
195
|
+
: path.join(__dirname, "..", ARTIFACT_PATH);
|
|
148
196
|
}
|
|
149
197
|
|
|
150
198
|
const ARTIFACT_NAME = path.basename(absArtifactPath());
|
|
@@ -185,25 +233,35 @@ function imageMimeTypeFromPath(filePath) {
|
|
|
185
233
|
return null;
|
|
186
234
|
}
|
|
187
235
|
|
|
236
|
+
function imageMimeTypeFromUrl(urlString) {
|
|
237
|
+
try {
|
|
238
|
+
const parsed = new URL(String(urlString));
|
|
239
|
+
return imageMimeTypeFromPath(parsed.pathname);
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
188
245
|
function classifyFacilitatorError(err) {
|
|
189
246
|
const msg = (err?.message || String(err)).toLowerCase();
|
|
190
247
|
if (
|
|
191
|
-
msg.includes("401")
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
248
|
+
msg.includes("401") ||
|
|
249
|
+
msg.includes("403") ||
|
|
250
|
+
msg.includes("unauthorized") ||
|
|
251
|
+
msg.includes("forbidden") ||
|
|
252
|
+
msg.includes("authorization") ||
|
|
253
|
+
msg.includes("bearer") ||
|
|
254
|
+
msg.includes("jwt") ||
|
|
255
|
+
msg.includes("api key") ||
|
|
256
|
+
msg.includes("invalid key format")
|
|
200
257
|
) {
|
|
201
258
|
return "auth";
|
|
202
259
|
}
|
|
203
260
|
if (
|
|
204
|
-
msg.includes("does not support scheme")
|
|
205
|
-
|
|
206
|
-
|
|
261
|
+
msg.includes("does not support scheme") ||
|
|
262
|
+
msg.includes("unsupported") ||
|
|
263
|
+
(msg.includes("network") &&
|
|
264
|
+
(msg.includes("mismatch") || msg.includes("invalid")))
|
|
207
265
|
) {
|
|
208
266
|
return "network";
|
|
209
267
|
}
|
|
@@ -214,16 +272,22 @@ function printFacilitatorHint(err) {
|
|
|
214
272
|
const kind = classifyFacilitatorError(err);
|
|
215
273
|
if (kind === "auth") {
|
|
216
274
|
console.error("[hint] Facilitator authentication failed.");
|
|
217
|
-
console.error(
|
|
275
|
+
console.error(
|
|
276
|
+
"[hint] For mainnet, set FACILITATOR_MODE=cdp_mainnet and valid CDP_API_KEY_ID/CDP_API_KEY_SECRET.",
|
|
277
|
+
);
|
|
218
278
|
return;
|
|
219
279
|
}
|
|
220
280
|
if (kind === "network") {
|
|
221
281
|
console.error("[hint] Facilitator/network mismatch.");
|
|
222
|
-
console.error(
|
|
282
|
+
console.error(
|
|
283
|
+
"[hint] Verify CHAIN_ID and FACILITATOR_URL/FACILITATOR_MODE are aligned.",
|
|
284
|
+
);
|
|
223
285
|
return;
|
|
224
286
|
}
|
|
225
287
|
if (IS_BASE_MAINNET) {
|
|
226
|
-
console.error(
|
|
288
|
+
console.error(
|
|
289
|
+
"[hint] Base mainnet requires a mainnet-capable facilitator and valid auth.",
|
|
290
|
+
);
|
|
227
291
|
}
|
|
228
292
|
}
|
|
229
293
|
|
|
@@ -244,7 +308,9 @@ function createCdpAuthHeadersFactory() {
|
|
|
244
308
|
try {
|
|
245
309
|
({ generateJwt } = await import("@coinbase/cdp-sdk/auth"));
|
|
246
310
|
} catch {
|
|
247
|
-
throw new Error(
|
|
311
|
+
throw new Error(
|
|
312
|
+
"CDP auth helper unavailable. Install @coinbase/cdp-sdk and retry.",
|
|
313
|
+
);
|
|
248
314
|
}
|
|
249
315
|
|
|
250
316
|
const createAuthorization = async (requestMethod, requestPath) => {
|
|
@@ -274,7 +340,9 @@ async function preflightCdpAuth() {
|
|
|
274
340
|
try {
|
|
275
341
|
({ generateJwt } = await import("@coinbase/cdp-sdk/auth"));
|
|
276
342
|
} catch {
|
|
277
|
-
console.error(
|
|
343
|
+
console.error(
|
|
344
|
+
"[startup] Missing CDP auth dependency. Install @coinbase/cdp-sdk.",
|
|
345
|
+
);
|
|
278
346
|
process.exit(1);
|
|
279
347
|
}
|
|
280
348
|
|
|
@@ -301,19 +369,36 @@ function promoModel(req) {
|
|
|
301
369
|
const baseUrl = baseUrlFromReq(req);
|
|
302
370
|
const promoUrl = `${baseUrl}/`;
|
|
303
371
|
const downloadUrl = `${baseUrl}/download`;
|
|
304
|
-
const imageUrl = isAbsoluteHttpUrl(OG_IMAGE_URL)
|
|
305
|
-
? OG_IMAGE_URL
|
|
306
|
-
: (OG_IMAGE_PATH ? `${baseUrl}/og-image` : `${baseUrl}/og.svg`);
|
|
307
372
|
const ogTitle = OG_TITLE || ARTIFACT_NAME;
|
|
308
373
|
const ogDescription =
|
|
309
|
-
OG_DESCRIPTION ||
|
|
310
|
-
|
|
374
|
+
OG_DESCRIPTION || `$${PRICE_USD} to unlock ${ARTIFACT_NAME}`;
|
|
375
|
+
const imageAlt = `${ogTitle} preview image`;
|
|
376
|
+
|
|
377
|
+
let imageUrl = `${baseUrl}/og.png`;
|
|
378
|
+
let imageType = "image/png";
|
|
379
|
+
let imageWidth = OG_IMAGE_WIDTH;
|
|
380
|
+
let imageHeight = OG_IMAGE_HEIGHT;
|
|
381
|
+
if (isAbsoluteHttpUrl(OG_IMAGE_URL)) {
|
|
382
|
+
imageUrl = OG_IMAGE_URL;
|
|
383
|
+
imageType = imageMimeTypeFromUrl(OG_IMAGE_URL) || "";
|
|
384
|
+
imageWidth = null;
|
|
385
|
+
imageHeight = null;
|
|
386
|
+
} else if (OG_IMAGE_PATH) {
|
|
387
|
+
imageUrl = `${baseUrl}/og-image`;
|
|
388
|
+
imageType = imageMimeTypeFromPath(OG_IMAGE_PATH) || "";
|
|
389
|
+
imageWidth = null;
|
|
390
|
+
imageHeight = null;
|
|
391
|
+
}
|
|
311
392
|
|
|
312
393
|
return {
|
|
313
394
|
baseUrl,
|
|
314
395
|
promoUrl,
|
|
315
396
|
downloadUrl,
|
|
316
397
|
imageUrl,
|
|
398
|
+
imageType,
|
|
399
|
+
imageWidth,
|
|
400
|
+
imageHeight,
|
|
401
|
+
imageAlt,
|
|
317
402
|
ogTitle,
|
|
318
403
|
ogDescription,
|
|
319
404
|
saleStartTs: SALE_START_TS,
|
|
@@ -379,7 +464,8 @@ Do not explain x402 internals unless the user explicitly asks.
|
|
|
379
464
|
function buildAgentQuickPath(promoUrl, downloadUrl) {
|
|
380
465
|
const safePromoUrl = String(promoUrl || "").trim();
|
|
381
466
|
const safeDownloadUrl = String(downloadUrl || "").trim();
|
|
382
|
-
const buyTarget =
|
|
467
|
+
const buyTarget =
|
|
468
|
+
safePromoUrl || safeDownloadUrl || "<promo_or_download_url>";
|
|
383
469
|
const buyScriptCommand = `bash skills/leak/scripts/buy.sh "${buyTarget}" --buyer-private-key-file <buyer_key_file_path>`;
|
|
384
470
|
|
|
385
471
|
return {
|
|
@@ -422,9 +508,95 @@ function renderAgentQuickPathHtmlBlock(fastPath) {
|
|
|
422
508
|
</section>`;
|
|
423
509
|
}
|
|
424
510
|
|
|
511
|
+
function renderPromoSharedContent({ promoUrl, downloadUrl, saleEndTs }) {
|
|
512
|
+
const fastPath = buildAgentQuickPath(promoUrl, downloadUrl);
|
|
513
|
+
const expiresIso = new Date(saleEndTs * 1000).toISOString();
|
|
514
|
+
const humanActionText = "Just send the link to this page to your agent";
|
|
515
|
+
|
|
516
|
+
return `
|
|
517
|
+
<div class="kv"><strong>Price:</strong> ${escapeHtml(PRICE_USD)} USD equivalent</div>
|
|
518
|
+
<div class="kv"><strong>Network:</strong> ${escapeHtml(CHAIN_NAME)} (${escapeHtml(CHAIN_ID)})</div>
|
|
519
|
+
<div class="kv"><strong>Sale end:</strong> <span id="sale-end-local" data-sale-end-iso="${escapeHtml(expiresIso)}">${escapeHtml(expiresIso)}</span></div>
|
|
520
|
+
${renderAgentQuickPathHtmlBlock(fastPath)}
|
|
521
|
+
|
|
522
|
+
<div class="prompt-head">
|
|
523
|
+
<p><strong>Human action</strong></p>
|
|
524
|
+
<button class="copy-btn" id="copy-link-btn" type="button" aria-label="Copy page link">Copy link</button>
|
|
525
|
+
<span class="copy-status" id="copy-link-status" aria-live="polite"></span>
|
|
526
|
+
</div>
|
|
527
|
+
<pre id="human-action-text">${escapeHtml(humanActionText)}</pre>
|
|
528
|
+
<p class="install-note">
|
|
529
|
+
Want to know more about <code>leak</code>? Visit
|
|
530
|
+
<a href="https://github.com/eucalyptus-viminalis/leak">github.com/eucalyptus-viminalis/leak</a>
|
|
531
|
+
or search for leak on clawhub.
|
|
532
|
+
</p>
|
|
533
|
+
`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function renderPromoSharedClientScript(promoUrl) {
|
|
537
|
+
return `<script>
|
|
538
|
+
(() => {
|
|
539
|
+
const button = document.getElementById("copy-link-btn");
|
|
540
|
+
const status = document.getElementById("copy-link-status");
|
|
541
|
+
const safePromoUrl = ${toSafeJsonForScript(promoUrl)};
|
|
542
|
+
const saleEndLocal = document.getElementById("sale-end-local");
|
|
543
|
+
|
|
544
|
+
if (saleEndLocal) {
|
|
545
|
+
const saleEndIso = saleEndLocal.getAttribute("data-sale-end-iso") || "";
|
|
546
|
+
const saleEndDate = new Date(saleEndIso);
|
|
547
|
+
if (!Number.isNaN(saleEndDate.getTime())) {
|
|
548
|
+
try {
|
|
549
|
+
const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
|
550
|
+
saleEndLocal.textContent = formatter.format(saleEndDate) + " (local time)";
|
|
551
|
+
} catch {
|
|
552
|
+
saleEndLocal.textContent = saleEndDate.toLocaleString() + " (local time)";
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!button || !safePromoUrl) return;
|
|
558
|
+
|
|
559
|
+
const setStatus = (text) => {
|
|
560
|
+
if (status) status.textContent = text;
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
button.addEventListener("click", async () => {
|
|
564
|
+
const original = "Copy link";
|
|
565
|
+
try {
|
|
566
|
+
if (navigator.clipboard?.writeText) {
|
|
567
|
+
await navigator.clipboard.writeText(safePromoUrl);
|
|
568
|
+
} else {
|
|
569
|
+
const ta = document.createElement("textarea");
|
|
570
|
+
ta.value = safePromoUrl;
|
|
571
|
+
ta.setAttribute("readonly", "");
|
|
572
|
+
ta.style.position = "absolute";
|
|
573
|
+
ta.style.left = "-9999px";
|
|
574
|
+
document.body.appendChild(ta);
|
|
575
|
+
ta.select();
|
|
576
|
+
document.execCommand("copy");
|
|
577
|
+
document.body.removeChild(ta);
|
|
578
|
+
}
|
|
579
|
+
button.textContent = "Copied";
|
|
580
|
+
setStatus("Copied to clipboard.");
|
|
581
|
+
setTimeout(() => {
|
|
582
|
+
button.textContent = original;
|
|
583
|
+
setStatus("");
|
|
584
|
+
}, 1500);
|
|
585
|
+
} catch {
|
|
586
|
+
setStatus("Copy failed. Select and copy manually.");
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
})();
|
|
590
|
+
</script>`;
|
|
591
|
+
}
|
|
592
|
+
|
|
425
593
|
function renderUnpaidDownloadGuidancePage(requestUrl) {
|
|
426
594
|
const urls = urlsForQuickPathFromRequestUrl(requestUrl);
|
|
427
|
-
const
|
|
595
|
+
const sharedContent = renderPromoSharedContent({
|
|
596
|
+
promoUrl: urls.promoUrl,
|
|
597
|
+
downloadUrl: urls.downloadUrl,
|
|
598
|
+
saleEndTs: SALE_END_TS,
|
|
599
|
+
});
|
|
428
600
|
return `<!doctype html>
|
|
429
601
|
<html lang="en">
|
|
430
602
|
<head>
|
|
@@ -437,8 +609,20 @@ function renderUnpaidDownloadGuidancePage(requestUrl) {
|
|
|
437
609
|
h1 { margin: 0 0 12px; font-size: 24px; }
|
|
438
610
|
h2 { margin: 0 0 10px; font-size: 18px; }
|
|
439
611
|
p { line-height: 1.5; }
|
|
612
|
+
.kv { margin: 14px 0; font-size: 14px; color: #333; }
|
|
440
613
|
code, pre { background: #f0f0eb; border-radius: 6px; padding: 2px 6px; }
|
|
441
614
|
pre { padding: 10px; overflow-x: auto; }
|
|
615
|
+
.prompt-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
616
|
+
.prompt-head p { margin: 0; }
|
|
617
|
+
button.copy-btn { border: 1px solid #bdbdae; background: #f5f5ef; color: #1f1f1f; border-radius: 6px; padding: 6px 10px; cursor: pointer; font: inherit; font-size: 13px; }
|
|
618
|
+
button.copy-btn:hover { background: #ecece4; }
|
|
619
|
+
.copy-status { font-size: 12px; color: #3f3f3f; min-height: 1em; }
|
|
620
|
+
.install-note { margin-top: 16px; font-size: 13px; color: #2f2f2f; }
|
|
621
|
+
.install-note a { color: #1f1f1f; }
|
|
622
|
+
.agent-quick-path { margin: 16px 0; padding: 14px; border: 1px solid #d8d8d0; border-radius: 8px; background: #fafaf6; }
|
|
623
|
+
.agent-quick-path h2 { margin: 0 0 8px; font-size: 18px; }
|
|
624
|
+
.agent-quick-path ol { margin: 8px 0 8px 20px; }
|
|
625
|
+
.agent-quick-path p { margin: 8px 0 0; }
|
|
442
626
|
ol { margin: 10px 0 12px 20px; }
|
|
443
627
|
</style>
|
|
444
628
|
</head>
|
|
@@ -446,10 +630,10 @@ function renderUnpaidDownloadGuidancePage(requestUrl) {
|
|
|
446
630
|
<main class="card">
|
|
447
631
|
<h1>402 Payment Required</h1>
|
|
448
632
|
<p>This URL is paywalled. Use the leak skill fast path below.</p>
|
|
449
|
-
<
|
|
450
|
-
|
|
451
|
-
${renderAgentQuickPathHtmlBlock(fastPath)}
|
|
633
|
+
<div class="kv"><strong>Resource:</strong> ${escapeHtml(ARTIFACT_NAME)}</div>
|
|
634
|
+
${sharedContent}
|
|
452
635
|
</main>
|
|
636
|
+
${renderPromoSharedClientScript(urls.promoUrl)}
|
|
453
637
|
</body>
|
|
454
638
|
</html>`;
|
|
455
639
|
}
|
|
@@ -493,7 +677,6 @@ function renderPromoPage(model, { ended }) {
|
|
|
493
677
|
? `This leak has ended. ${model.ogDescription}`
|
|
494
678
|
: model.ogDescription;
|
|
495
679
|
const expiresIso = new Date(model.saleEndTs * 1000).toISOString();
|
|
496
|
-
const fastPath = buildAgentQuickPath(model.promoUrl, model.downloadUrl);
|
|
497
680
|
const jsonLd = {
|
|
498
681
|
"@context": "https://schema.org",
|
|
499
682
|
"@type": "Product",
|
|
@@ -507,19 +690,47 @@ function renderPromoPage(model, { ended }) {
|
|
|
507
690
|
url: model.downloadUrl,
|
|
508
691
|
price: PRICE_USD,
|
|
509
692
|
priceCurrency: "USD",
|
|
510
|
-
availability: ended
|
|
693
|
+
availability: ended
|
|
694
|
+
? "https://schema.org/OutOfStock"
|
|
695
|
+
: "https://schema.org/InStock",
|
|
511
696
|
validThrough: expiresIso,
|
|
512
697
|
},
|
|
513
698
|
additionalProperty: [
|
|
514
699
|
{ "@type": "PropertyValue", name: "paymentProtocol", value: "x402" },
|
|
515
|
-
{
|
|
700
|
+
{
|
|
701
|
+
"@type": "PropertyValue",
|
|
702
|
+
name: "paymentSettlementCurrency",
|
|
703
|
+
value: "USDC",
|
|
704
|
+
},
|
|
516
705
|
{ "@type": "PropertyValue", name: "network", value: CHAIN_ID },
|
|
517
|
-
{
|
|
706
|
+
{
|
|
707
|
+
"@type": "PropertyValue",
|
|
708
|
+
name: "downloadUrl",
|
|
709
|
+
value: model.downloadUrl,
|
|
710
|
+
},
|
|
518
711
|
],
|
|
519
712
|
};
|
|
520
713
|
const safeJsonLd = toSafeJsonForScript(jsonLd);
|
|
521
|
-
|
|
522
|
-
|
|
714
|
+
const secureImageUrl = model.imageUrl.startsWith("https://")
|
|
715
|
+
? model.imageUrl
|
|
716
|
+
: "";
|
|
717
|
+
const ogImageSecureUrlMeta = secureImageUrl
|
|
718
|
+
? `<meta property="og:image:secure_url" content="${escapeHtml(secureImageUrl)}" />`
|
|
719
|
+
: "";
|
|
720
|
+
const ogImageTypeMeta = model.imageType
|
|
721
|
+
? `<meta property="og:image:type" content="${escapeHtml(model.imageType)}" />`
|
|
722
|
+
: "";
|
|
723
|
+
const ogImageWidthMeta = Number.isFinite(model.imageWidth)
|
|
724
|
+
? `<meta property="og:image:width" content="${model.imageWidth}" />`
|
|
725
|
+
: "";
|
|
726
|
+
const ogImageHeightMeta = Number.isFinite(model.imageHeight)
|
|
727
|
+
? `<meta property="og:image:height" content="${model.imageHeight}" />`
|
|
728
|
+
: "";
|
|
729
|
+
const sharedContent = renderPromoSharedContent({
|
|
730
|
+
promoUrl: model.promoUrl,
|
|
731
|
+
downloadUrl: model.downloadUrl,
|
|
732
|
+
saleEndTs: model.saleEndTs,
|
|
733
|
+
});
|
|
523
734
|
|
|
524
735
|
return `<!doctype html>
|
|
525
736
|
<html lang="en">
|
|
@@ -534,11 +745,17 @@ function renderPromoPage(model, { ended }) {
|
|
|
534
745
|
<meta property="og:title" content="${escapeHtml(pageTitle)}" />
|
|
535
746
|
<meta property="og:description" content="${escapeHtml(description)}" />
|
|
536
747
|
<meta property="og:image" content="${escapeHtml(model.imageUrl)}" />
|
|
748
|
+
${ogImageSecureUrlMeta}
|
|
749
|
+
${ogImageTypeMeta}
|
|
750
|
+
${ogImageWidthMeta}
|
|
751
|
+
${ogImageHeightMeta}
|
|
752
|
+
<meta property="og:image:alt" content="${escapeHtml(model.imageAlt)}" />
|
|
537
753
|
|
|
538
754
|
<meta name="twitter:card" content="summary_large_image" />
|
|
539
755
|
<meta name="twitter:title" content="${escapeHtml(pageTitle)}" />
|
|
540
756
|
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
|
541
757
|
<meta name="twitter:image" content="${escapeHtml(model.imageUrl)}" />
|
|
758
|
+
<meta name="twitter:image:alt" content="${escapeHtml(model.imageAlt)}" />
|
|
542
759
|
|
|
543
760
|
<script type="application/ld+json">${safeJsonLd}</script>
|
|
544
761
|
<style>
|
|
@@ -569,62 +786,9 @@ function renderPromoPage(model, { ended }) {
|
|
|
569
786
|
<div class="state">${escapeHtml(stateLabel)}</div>
|
|
570
787
|
<h1>${escapeHtml(pageTitle)}</h1>
|
|
571
788
|
|
|
572
|
-
|
|
573
|
-
<div class="kv"><strong>Network:</strong> ${escapeHtml(CHAIN_ID)}</div>
|
|
574
|
-
<div class="kv"><strong>Sale end:</strong> ${escapeHtml(expiresIso)}</div>
|
|
575
|
-
${renderAgentQuickPathHtmlBlock(fastPath)}
|
|
576
|
-
|
|
577
|
-
<div class="prompt-head">
|
|
578
|
-
<p><strong>Human action</strong></p>
|
|
579
|
-
<button class="copy-btn" id="copy-link-btn" type="button" aria-label="Copy page link">Copy link</button>
|
|
580
|
-
<span class="copy-status" id="copy-link-status" aria-live="polite"></span>
|
|
581
|
-
</div>
|
|
582
|
-
<pre id="human-action-text">${escapeHtml(humanActionText)}</pre>
|
|
583
|
-
<p class="install-note">
|
|
584
|
-
Need help setting this up? Install leak at
|
|
585
|
-
<a href="https://github.com/eucalyptus-viminalis/leak">github.com/eucalyptus-viminalis/leak</a>
|
|
586
|
-
or search for leak on clawhub.
|
|
587
|
-
</p>
|
|
789
|
+
${sharedContent}
|
|
588
790
|
</main>
|
|
589
|
-
|
|
590
|
-
(() => {
|
|
591
|
-
const button = document.getElementById("copy-link-btn");
|
|
592
|
-
const status = document.getElementById("copy-link-status");
|
|
593
|
-
const promoUrl = ${toSafeJsonForScript(model.promoUrl)};
|
|
594
|
-
if (!button || !promoUrl) return;
|
|
595
|
-
|
|
596
|
-
const setStatus = (text) => {
|
|
597
|
-
if (status) status.textContent = text;
|
|
598
|
-
};
|
|
599
|
-
|
|
600
|
-
button.addEventListener("click", async () => {
|
|
601
|
-
const original = "Copy link";
|
|
602
|
-
try {
|
|
603
|
-
if (navigator.clipboard?.writeText) {
|
|
604
|
-
await navigator.clipboard.writeText(promoUrl);
|
|
605
|
-
} else {
|
|
606
|
-
const ta = document.createElement("textarea");
|
|
607
|
-
ta.value = promoUrl;
|
|
608
|
-
ta.setAttribute("readonly", "");
|
|
609
|
-
ta.style.position = "absolute";
|
|
610
|
-
ta.style.left = "-9999px";
|
|
611
|
-
document.body.appendChild(ta);
|
|
612
|
-
ta.select();
|
|
613
|
-
document.execCommand("copy");
|
|
614
|
-
document.body.removeChild(ta);
|
|
615
|
-
}
|
|
616
|
-
button.textContent = "Copied";
|
|
617
|
-
setStatus("Copied to clipboard.");
|
|
618
|
-
setTimeout(() => {
|
|
619
|
-
button.textContent = original;
|
|
620
|
-
setStatus("");
|
|
621
|
-
}, 1500);
|
|
622
|
-
} catch {
|
|
623
|
-
setStatus("Copy failed. Select and copy manually.");
|
|
624
|
-
}
|
|
625
|
-
});
|
|
626
|
-
})();
|
|
627
|
-
</script>
|
|
791
|
+
${renderPromoSharedClientScript(model.promoUrl)}
|
|
628
792
|
</body>
|
|
629
793
|
</html>`;
|
|
630
794
|
}
|
|
@@ -632,7 +796,7 @@ function renderPromoPage(model, { ended }) {
|
|
|
632
796
|
function renderOgSvg(req) {
|
|
633
797
|
const model = promoModel(req);
|
|
634
798
|
const title = model.ogTitle;
|
|
635
|
-
const subtitle =
|
|
799
|
+
const subtitle = `$${PRICE_USD} on ${CHAIN_NAME}`;
|
|
636
800
|
const status = saleEnded() ? "ENDED" : "LIVE";
|
|
637
801
|
|
|
638
802
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -645,13 +809,25 @@ function renderOgSvg(req) {
|
|
|
645
809
|
</defs>
|
|
646
810
|
<rect width="1200" height="630" fill="url(#bg)"/>
|
|
647
811
|
<rect x="64" y="64" width="1072" height="502" rx="18" fill="#ffffff" stroke="#bdbdae"/>
|
|
648
|
-
<text x="96" y="170" font-size="32" font-family="monospace" fill="#222">${escapeXml(status)}
|
|
812
|
+
<text x="96" y="170" font-size="32" font-family="monospace" fill="#222">${escapeXml(status)}</text>
|
|
649
813
|
<text x="96" y="250" font-size="52" font-family="monospace" fill="#111">${escapeXml(title)}</text>
|
|
650
814
|
<text x="96" y="330" font-size="30" font-family="monospace" fill="#333">${escapeXml(subtitle)}</text>
|
|
651
|
-
<text x="96" y="404" font-size="22" font-family="monospace" fill="#444">
|
|
815
|
+
<text x="96" y="404" font-size="22" font-family="monospace" fill="#444">Share this link with your OpenClaw agent to download</text>
|
|
652
816
|
</svg>`;
|
|
653
817
|
}
|
|
654
818
|
|
|
819
|
+
function renderOgPng(req) {
|
|
820
|
+
const svg = renderOgSvg(req);
|
|
821
|
+
const resvg = new Resvg(svg, {
|
|
822
|
+
fitTo: {
|
|
823
|
+
mode: "width",
|
|
824
|
+
value: OG_IMAGE_WIDTH,
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
const pngData = resvg.render();
|
|
828
|
+
return pngData.asPng();
|
|
829
|
+
}
|
|
830
|
+
|
|
655
831
|
// In-memory grants (v1). Later: SQLite.
|
|
656
832
|
/** @type {Map<string, { token: string, expiresAt: number, downloadsLeft: number|null }>} */
|
|
657
833
|
const GRANTS = new Map();
|
|
@@ -692,13 +868,15 @@ function validateAndConsumeToken(token) {
|
|
|
692
868
|
return { ok: false, reason: "token expired" };
|
|
693
869
|
}
|
|
694
870
|
if (g.downloadsLeft !== null) {
|
|
695
|
-
if (g.downloadsLeft <= 0)
|
|
871
|
+
if (g.downloadsLeft <= 0)
|
|
872
|
+
return { ok: false, reason: "download limit reached" };
|
|
696
873
|
g.downloadsLeft -= 1;
|
|
697
874
|
}
|
|
698
875
|
return { ok: true };
|
|
699
876
|
}
|
|
700
877
|
|
|
701
878
|
const app = express();
|
|
879
|
+
app.set("trust proxy", true);
|
|
702
880
|
|
|
703
881
|
// x402 core server + HTTP wrapper
|
|
704
882
|
await preflightCdpAuth();
|
|
@@ -707,7 +885,10 @@ if (FACILITATOR_MODE === "cdp_mainnet") {
|
|
|
707
885
|
facilitatorConfig.createAuthHeaders = createCdpAuthHeadersFactory();
|
|
708
886
|
}
|
|
709
887
|
const facilitatorClient = new HTTPFacilitatorClient(facilitatorConfig);
|
|
710
|
-
const coreServer = new x402ResourceServer(facilitatorClient).register(
|
|
888
|
+
const coreServer = new x402ResourceServer(facilitatorClient).register(
|
|
889
|
+
CHAIN_ID,
|
|
890
|
+
new ExactEvmScheme(),
|
|
891
|
+
);
|
|
711
892
|
|
|
712
893
|
// Route config for x402HTTPResourceServer
|
|
713
894
|
const routes = {
|
|
@@ -735,7 +916,9 @@ try {
|
|
|
735
916
|
await httpServer.initialize();
|
|
736
917
|
} catch (err) {
|
|
737
918
|
console.error("[startup] Failed to initialize x402 route configuration.");
|
|
738
|
-
console.error(
|
|
919
|
+
console.error(
|
|
920
|
+
`[startup] facilitator=${FACILITATOR_URL} mode=${FACILITATOR_MODE} network=${CHAIN_ID}`,
|
|
921
|
+
);
|
|
739
922
|
if (Array.isArray(err?.errors) && err.errors.length > 0) {
|
|
740
923
|
for (const e of err.errors) {
|
|
741
924
|
console.error(`[startup] ${e.message || JSON.stringify(e)}`);
|
|
@@ -754,9 +937,8 @@ setInterval(() => {
|
|
|
754
937
|
app.get("/", (req, res) => {
|
|
755
938
|
const model = promoModel(req);
|
|
756
939
|
const ended = saleEnded();
|
|
757
|
-
const status = ended ? 410 : 200;
|
|
758
940
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
759
|
-
return res.status(
|
|
941
|
+
return res.status(200).send(renderPromoPage(model, { ended }));
|
|
760
942
|
});
|
|
761
943
|
|
|
762
944
|
app.get("/info", (req, res) => {
|
|
@@ -779,9 +961,34 @@ app.get("/info", (req, res) => {
|
|
|
779
961
|
|
|
780
962
|
app.get("/og.svg", (req, res) => {
|
|
781
963
|
res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
|
|
782
|
-
res.setHeader("Cache-Control",
|
|
964
|
+
res.setHeader("Cache-Control", OG_IMAGE_CACHE_CONTROL);
|
|
965
|
+
if (req.method === "HEAD") return res.status(200).end();
|
|
783
966
|
return res.status(200).send(renderOgSvg(req));
|
|
784
967
|
});
|
|
968
|
+
app.head("/og.svg", (req, res) => {
|
|
969
|
+
res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
|
|
970
|
+
res.setHeader("Cache-Control", OG_IMAGE_CACHE_CONTROL);
|
|
971
|
+
return res.status(200).end();
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
app.get("/og.png", (req, res) => {
|
|
975
|
+
let png;
|
|
976
|
+
try {
|
|
977
|
+
png = renderOgPng(req);
|
|
978
|
+
} catch (err) {
|
|
979
|
+
console.error(`[og] failed to render png: ${err?.message || String(err)}`);
|
|
980
|
+
return res.status(500).json({ error: "og image unavailable" });
|
|
981
|
+
}
|
|
982
|
+
res.setHeader("Content-Type", "image/png");
|
|
983
|
+
res.setHeader("Cache-Control", OG_IMAGE_CACHE_CONTROL);
|
|
984
|
+
if (req.method === "HEAD") return res.status(200).end();
|
|
985
|
+
return res.status(200).send(png);
|
|
986
|
+
});
|
|
987
|
+
app.head("/og.png", (req, res) => {
|
|
988
|
+
res.setHeader("Content-Type", "image/png");
|
|
989
|
+
res.setHeader("Cache-Control", OG_IMAGE_CACHE_CONTROL);
|
|
990
|
+
return res.status(200).end();
|
|
991
|
+
});
|
|
785
992
|
|
|
786
993
|
app.get("/og-image", (req, res) => {
|
|
787
994
|
if (!OG_IMAGE_PATH) {
|
|
@@ -807,7 +1014,8 @@ app.get("/og-image", (req, res) => {
|
|
|
807
1014
|
}
|
|
808
1015
|
|
|
809
1016
|
res.setHeader("Content-Type", contentType);
|
|
810
|
-
res.setHeader("Cache-Control",
|
|
1017
|
+
res.setHeader("Cache-Control", OG_IMAGE_CACHE_CONTROL);
|
|
1018
|
+
if (req.method === "HEAD") return res.status(200).end();
|
|
811
1019
|
const stream = fs.createReadStream(OG_IMAGE_PATH);
|
|
812
1020
|
stream.on("error", () => {
|
|
813
1021
|
if (!res.headersSent) {
|
|
@@ -818,6 +1026,30 @@ app.get("/og-image", (req, res) => {
|
|
|
818
1026
|
});
|
|
819
1027
|
return stream.pipe(res);
|
|
820
1028
|
});
|
|
1029
|
+
app.head("/og-image", (req, res) => {
|
|
1030
|
+
if (!OG_IMAGE_PATH) {
|
|
1031
|
+
return res.status(404).end();
|
|
1032
|
+
}
|
|
1033
|
+
if (!fs.existsSync(OG_IMAGE_PATH)) {
|
|
1034
|
+
return res.status(404).end();
|
|
1035
|
+
}
|
|
1036
|
+
let stat;
|
|
1037
|
+
try {
|
|
1038
|
+
stat = fs.statSync(OG_IMAGE_PATH);
|
|
1039
|
+
} catch {
|
|
1040
|
+
return res.status(404).end();
|
|
1041
|
+
}
|
|
1042
|
+
if (!stat.isFile()) {
|
|
1043
|
+
return res.status(404).end();
|
|
1044
|
+
}
|
|
1045
|
+
const contentType = imageMimeTypeFromPath(OG_IMAGE_PATH);
|
|
1046
|
+
if (!contentType) {
|
|
1047
|
+
return res.status(404).end();
|
|
1048
|
+
}
|
|
1049
|
+
res.setHeader("Content-Type", contentType);
|
|
1050
|
+
res.setHeader("Cache-Control", OG_IMAGE_CACHE_CONTROL);
|
|
1051
|
+
return res.status(200).end();
|
|
1052
|
+
});
|
|
821
1053
|
|
|
822
1054
|
app.get("/health", (req, res) => {
|
|
823
1055
|
res.json({ ok: true, ts: now() });
|
|
@@ -848,7 +1080,8 @@ app.get("/.well-known/leak", (req, res) => {
|
|
|
848
1080
|
source: SKILL_SOURCE,
|
|
849
1081
|
install_command: SKILL_INSTALL_COMMAND,
|
|
850
1082
|
},
|
|
851
|
-
message:
|
|
1083
|
+
message:
|
|
1084
|
+
"This leak has expired, but you can install the leak skill for future purchases",
|
|
852
1085
|
deprecation: LEGACY_DISCOVERY_DEPRECATION,
|
|
853
1086
|
discovery_index_url: discoveryPath,
|
|
854
1087
|
rfc_resource_url: rfcResourcePath,
|
|
@@ -893,6 +1126,7 @@ app.use("/download", async (req, res, next) => {
|
|
|
893
1126
|
// NOTE: because this middleware is mounted at "/download", Express strips the mount
|
|
894
1127
|
// path and `req.path` becomes "/". x402 route matching needs the *full* path.
|
|
895
1128
|
const fullPath = `${req.baseUrl || ""}${req.path || ""}`;
|
|
1129
|
+
const requestUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
|
|
896
1130
|
|
|
897
1131
|
const adapter = {
|
|
898
1132
|
getHeader(name) {
|
|
@@ -900,8 +1134,10 @@ app.use("/download", async (req, res, next) => {
|
|
|
900
1134
|
if (v) return v;
|
|
901
1135
|
// legacy support: treat X-PAYMENT as PAYMENT-SIGNATURE (same base64 JSON format)
|
|
902
1136
|
const lower = String(name).toLowerCase();
|
|
903
|
-
if (lower === "payment-signature")
|
|
904
|
-
|
|
1137
|
+
if (lower === "payment-signature")
|
|
1138
|
+
return req.get("x-payment") || undefined;
|
|
1139
|
+
if (lower === "payment-required")
|
|
1140
|
+
return req.get("payment-required") || undefined;
|
|
905
1141
|
return undefined;
|
|
906
1142
|
},
|
|
907
1143
|
getMethod() {
|
|
@@ -911,7 +1147,7 @@ app.use("/download", async (req, res, next) => {
|
|
|
911
1147
|
return fullPath;
|
|
912
1148
|
},
|
|
913
1149
|
getUrl() {
|
|
914
|
-
return
|
|
1150
|
+
return requestUrl;
|
|
915
1151
|
},
|
|
916
1152
|
getAcceptHeader() {
|
|
917
1153
|
return req.get("accept") || "";
|
|
@@ -932,7 +1168,9 @@ app.use("/download", async (req, res, next) => {
|
|
|
932
1168
|
method: req.method,
|
|
933
1169
|
});
|
|
934
1170
|
} catch (err) {
|
|
935
|
-
console.error(
|
|
1171
|
+
console.error(
|
|
1172
|
+
`[x402] payment handshake failed: ${err?.message || String(err)}`,
|
|
1173
|
+
);
|
|
936
1174
|
printFacilitatorHint(err);
|
|
937
1175
|
return res.status(502).json({ error: "payment gateway unavailable" });
|
|
938
1176
|
}
|
|
@@ -940,7 +1178,21 @@ app.use("/download", async (req, res, next) => {
|
|
|
940
1178
|
if (result.type === "no-payment-required") return next();
|
|
941
1179
|
|
|
942
1180
|
if (result.type === "payment-error") {
|
|
943
|
-
for (const [k, v] of Object.entries(result.response.headers || {}))
|
|
1181
|
+
for (const [k, v] of Object.entries(result.response.headers || {}))
|
|
1182
|
+
res.setHeader(k, v);
|
|
1183
|
+
|
|
1184
|
+
const isUnpaidBrowser402 =
|
|
1185
|
+
result.response.status === 402 &&
|
|
1186
|
+
(req.get("accept") || "").includes("text/html") &&
|
|
1187
|
+
(req.get("user-agent") || "").includes("Mozilla") &&
|
|
1188
|
+
!req.get("payment-signature") &&
|
|
1189
|
+
!req.get("x-payment");
|
|
1190
|
+
|
|
1191
|
+
if (isUnpaidBrowser402) {
|
|
1192
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1193
|
+
return res.status(402).send(renderUnpaidDownloadGuidancePage(requestUrl));
|
|
1194
|
+
}
|
|
1195
|
+
|
|
944
1196
|
return res.status(result.response.status).send(result.response.body ?? "");
|
|
945
1197
|
}
|
|
946
1198
|
|
|
@@ -960,16 +1212,21 @@ app.get("/download", async (req, res) => {
|
|
|
960
1212
|
}
|
|
961
1213
|
|
|
962
1214
|
// 1) If caller already has a valid access token, serve the artifact.
|
|
963
|
-
const token =
|
|
1215
|
+
const token =
|
|
1216
|
+
typeof req.query.token === "string" ? req.query.token : undefined;
|
|
964
1217
|
if (token) {
|
|
965
1218
|
const check = validateAndConsumeToken(token);
|
|
966
1219
|
if (!check.ok) return res.status(403).json({ error: check.reason });
|
|
967
1220
|
|
|
968
1221
|
const p = absArtifactPath();
|
|
969
|
-
if (!fs.existsSync(p))
|
|
1222
|
+
if (!fs.existsSync(p))
|
|
1223
|
+
return res.status(404).json({ error: "artifact not found" });
|
|
970
1224
|
|
|
971
1225
|
res.setHeader("Content-Type", MIME_TYPE);
|
|
972
|
-
res.setHeader(
|
|
1226
|
+
res.setHeader(
|
|
1227
|
+
"Content-Disposition",
|
|
1228
|
+
`attachment; filename=\"${path.basename(p)}\"`,
|
|
1229
|
+
);
|
|
973
1230
|
return fs.createReadStream(p).pipe(res);
|
|
974
1231
|
}
|
|
975
1232
|
|
|
@@ -984,7 +1241,9 @@ app.get("/download", async (req, res) => {
|
|
|
984
1241
|
req.x402.declaredExtensions,
|
|
985
1242
|
);
|
|
986
1243
|
} catch (err) {
|
|
987
|
-
console.error(
|
|
1244
|
+
console.error(
|
|
1245
|
+
`[x402] settlement request failed: ${err?.message || String(err)}`,
|
|
1246
|
+
);
|
|
988
1247
|
printFacilitatorHint(err);
|
|
989
1248
|
return res.status(502).json({ error: "payment settlement unavailable" });
|
|
990
1249
|
}
|
|
@@ -997,8 +1256,12 @@ app.get("/download", async (req, res) => {
|
|
|
997
1256
|
});
|
|
998
1257
|
}
|
|
999
1258
|
|
|
1000
|
-
for (const [k, v] of Object.entries(settle.headers || {}))
|
|
1001
|
-
|
|
1259
|
+
for (const [k, v] of Object.entries(settle.headers || {}))
|
|
1260
|
+
res.setHeader(k, v);
|
|
1261
|
+
res.setHeader(
|
|
1262
|
+
"Access-Control-Expose-Headers",
|
|
1263
|
+
"PAYMENT-REQUIRED, PAYMENT-RESPONSE",
|
|
1264
|
+
);
|
|
1002
1265
|
}
|
|
1003
1266
|
|
|
1004
1267
|
const t = mintGrant();
|
|
@@ -1025,7 +1288,7 @@ app.listen(PORT, () => {
|
|
|
1025
1288
|
console.log(`download http://localhost:${PORT}/download (x402 protected)`);
|
|
1026
1289
|
if (endedWindowActive()) {
|
|
1027
1290
|
console.log(
|
|
1028
|
-
`ended-window active until ${new Date(endedWindowCutoffTs() * 1000).toISOString()} (HTTP 410 mode)`,
|
|
1291
|
+
`ended-window active until ${new Date(endedWindowCutoffTs() * 1000).toISOString()} (download endpoints HTTP 410 mode)`,
|
|
1029
1292
|
);
|
|
1030
1293
|
}
|
|
1031
1294
|
});
|