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 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
- - `410` once sale has ended
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.svg` fallback OG image (used when `--og-image-url` is not set)
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.15-beta.1",
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
- const chainId = await askWithDefault(
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 network = args.network || process.env.CHAIN_ID || configDefaults.chainId || "eip155:84532";
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 = "https://api.cdp.coinbase.com/platform/v2/x402";
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" ? DEFAULT_CDP_MAINNET_FACILITATOR_URL : DEFAULT_TESTNET_FACILITATOR_URL)
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 CHAIN_ID = process.env.CHAIN_ID || process.env.NETWORK || "eip155:84532";
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(process.env.GRANT_SWEEP_SECONDS, 60);
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
- ? (path.isAbsolute(OG_IMAGE_PATH_RAW) ? OG_IMAGE_PATH_RAW : path.join(__dirname, "..", OG_IMAGE_PATH_RAW))
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 = "Sell or buy x402-gated digital content using the leak CLI tool";
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(process.env.SALE_END_TS, SALE_START_TS + WINDOW_SECONDS);
112
- const ENDED_WINDOW_SECONDS = parseNonNegativeInt(process.env.ENDED_WINDOW_SECONDS, 0);
113
- const IS_BASE_MAINNET = CHAIN_ID === "eip155:8453";
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("Invalid FACILITATOR_MODE. Supported values: testnet, cdp_mainnet");
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("Invalid config: CHAIN_ID=eip155:8453 requires FACILITATOR_MODE=cdp_mainnet.");
122
- console.error("Set FACILITATOR_MODE=cdp_mainnet and configure CDP_API_KEY_ID/CDP_API_KEY_SECRET.");
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 (FACILITATOR_MODE === "cdp_mainnet" && (!CDP_API_KEY_ID || !CDP_API_KEY_SECRET)) {
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("Set CDP_API_KEY_ID and CDP_API_KEY_SECRET in your environment.");
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) ? ARTIFACT_PATH : path.join(__dirname, "..", 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
- || msg.includes("403")
193
- || msg.includes("unauthorized")
194
- || msg.includes("forbidden")
195
- || msg.includes("authorization")
196
- || msg.includes("bearer")
197
- || msg.includes("jwt")
198
- || msg.includes("api key")
199
- || msg.includes("invalid key format")
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
- || msg.includes("unsupported")
206
- || (msg.includes("network") && (msg.includes("mismatch") || msg.includes("invalid")))
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("[hint] For mainnet, set FACILITATOR_MODE=cdp_mainnet and valid CDP_API_KEY_ID/CDP_API_KEY_SECRET.");
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("[hint] Verify CHAIN_ID and FACILITATOR_URL/FACILITATOR_MODE are aligned.");
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("[hint] Base mainnet requires a mainnet-capable facilitator and valid auth.");
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("CDP auth helper unavailable. Install @coinbase/cdp-sdk and retry.");
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("[startup] Missing CDP auth dependency. Install @coinbase/cdp-sdk.");
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
- `Pay ${PRICE_USD} on ${CHAIN_ID} to unlock ${ARTIFACT_NAME}. Access is time-limited and agent-assisted via /download.`;
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 = safePromoUrl || safeDownloadUrl || "<promo_or_download_url>";
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 fastPath = buildAgentQuickPath(urls.promoUrl, urls.downloadUrl);
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
- <p><strong>Promo URL:</strong> <code>${escapeHtml(fastPath.promoUrl)}</code></p>
450
- <p><strong>x402 URL:</strong> <code>${escapeHtml(fastPath.downloadUrl)}</code></p>
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 ? "https://schema.org/OutOfStock" : "https://schema.org/InStock",
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
- { "@type": "PropertyValue", name: "paymentSettlementCurrency", value: "USDC" },
700
+ {
701
+ "@type": "PropertyValue",
702
+ name: "paymentSettlementCurrency",
703
+ value: "USDC",
704
+ },
516
705
  { "@type": "PropertyValue", name: "network", value: CHAIN_ID },
517
- { "@type": "PropertyValue", name: "downloadUrl", value: model.downloadUrl },
706
+ {
707
+ "@type": "PropertyValue",
708
+ name: "downloadUrl",
709
+ value: model.downloadUrl,
710
+ },
518
711
  ],
519
712
  };
520
713
  const safeJsonLd = toSafeJsonForScript(jsonLd);
521
-
522
- const humanActionText = "Just send the link to this page to your agent";
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
- <div class="kv"><strong>Price:</strong> ${escapeHtml(PRICE_USD)} USD equivalent</div>
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
- <script>
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 = `Pay ${PRICE_USD} on ${CHAIN_ID}`;
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)} LEAK</text>
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">x402 via /download</text>
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) return { ok: false, reason: "download limit reached" };
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(CHAIN_ID, new ExactEvmScheme());
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(`[startup] facilitator=${FACILITATOR_URL} mode=${FACILITATOR_MODE} network=${CHAIN_ID}`);
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(status).send(renderPromoPage(model, { ended }));
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", "public, max-age=60");
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", "public, max-age=60");
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: "This leak has expired, but you can install the leak skill for future purchases",
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") return req.get("x-payment") || undefined;
904
- if (lower === "payment-required") return req.get("payment-required") || undefined;
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 `${req.protocol}://${req.get("host")}${req.originalUrl}`;
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(`[x402] payment handshake failed: ${err?.message || String(err)}`);
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 || {})) res.setHeader(k, v);
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 = typeof req.query.token === "string" ? req.query.token : undefined;
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)) return res.status(404).json({ error: "artifact not found" });
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("Content-Disposition", `attachment; filename=\"${path.basename(p)}\"`);
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(`[x402] settlement request failed: ${err?.message || String(err)}`);
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 || {})) res.setHeader(k, v);
1001
- res.setHeader("Access-Control-Expose-Headers", "PAYMENT-REQUIRED, PAYMENT-RESPONSE");
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
  });