leak-cli 2026.2.15 → 2026.2.17-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -13
  2. package/package.json +4 -2
  3. package/src/index.js +373 -138
package/README.md CHANGED
@@ -18,7 +18,9 @@ Package name: `leak-cli`
18
18
 
19
19
  Command: `leak`
20
20
 
21
- OpenClaw skill docs live in this repo at [`/skills/leak`](https://github.com/eucalyptus-viminalis/leak/tree/main/skills/leak); Clawhub listing coming soon.
21
+ OpenClaw skill docs live in this repo at:
22
+ - [`/skills/leak-buy`](https://github.com/eucalyptus-viminalis/leak/tree/main/skills/leak-buy)
23
+ - [`/skills/leak-publish`](https://github.com/eucalyptus-viminalis/leak/tree/main/skills/leak-publish)
22
24
 
23
25
  ### Config
24
26
 
@@ -39,7 +41,7 @@ leak --file ./your-file.bin --public
39
41
  **Buying**:
40
42
 
41
43
  ```bash
42
- leak buy <promo_or_download_link> (--buyer-private-key-file <path> | --buyer-private-key-stdin)
44
+ leak buy <promo_or_download_link> --buyer-private-key-file <path>
43
45
  ```
44
46
 
45
47
  ### Seller Quickstart 1: Local testnet sale (fastest path)
@@ -98,19 +100,17 @@ Security note: use a dedicated buyer key with limited funds.
98
100
 
99
101
  ### Buyer Skeleton (Clawhub skill flow)
100
102
 
101
- - install the `leak` skill from Clawhub
103
+ - install the `leak-buy` skill from Clawhub
102
104
  - give your agent the promo URL (`/`) from the post (or `/download`)
103
105
  - provide a funded buyer key when prompted
104
106
  - let the agent complete payment + download through the skill
105
107
 
106
- Under the hood, the skill scripts try `leak` on PATH first and fall back to `npx -y leak-cli@2026.2.14` for one-off usage if needed.
108
+ The hardened skills require a preinstalled `leak` binary on PATH.
107
109
 
108
110
  Recommended first-time agent UX for unknown URLs:
109
- - ask only for skill-install approval (`clawhub install leak`)
110
- - ask for an existing buyer key file path (default mode)
111
- - if user opts in and no key exists, create `./.leak/buyer.key` (hot wallet; fund minimally; user must back it up)
112
- - if workspace is a git repo, add `./.leak/buyer.key` to `.gitignore` so it is not tracked
113
- - run: `bash skills/leak/scripts/buy.sh "<promo_or_download_url>" --buyer-private-key-file <buyer_key_file_path>` (or `./.leak/buyer.key` for generated fallback)
111
+ - ask only for skill-install approval (`clawhub install leak-buy`)
112
+ - ask for an existing buyer key file path
113
+ - run: `bash skills/leak-buy/scripts/buy.sh "<promo_or_download_url>" --buyer-private-key-file <buyer_key_file_path>`
114
114
  - avoid protocol deep-dives unless the user explicitly asks for x402 internals
115
115
 
116
116
  ### Next: Mainnet checklist (optional)
@@ -135,7 +135,7 @@ Reference: see [Testnet vs Mainnet facilitator setup](#testnet-vs-mainnet-facili
135
135
  - set available window 🪟
136
136
  - tell your agent what you b *leakin'* 🤤
137
137
  - `💦 on-demand + one-shot + ephemeral ✨ -- store for your digital goods`
138
- - yes, install the `leak` 🦞 `OpenClaw` 🦞 `SKILL` and you can tell your agent to do all the above for you 🪬
138
+ - yes, install `leak-publish` (seller) and `leak-buy` (buyer) OpenClaw skills and let your agent run those flows 🪬
139
139
 
140
140
  **Buyer**:
141
141
  - download cool sh!t straight to your device
@@ -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
 
@@ -501,6 +504,8 @@ Release rules:
501
504
  - Keep versions synchronized:
502
505
  - `package.json`
503
506
  - `skills/leak/SKILL.md`
507
+ - `skills/leak-buy/SKILL.md`
508
+ - `skills/leak-publish/SKILL.md`
504
509
  - Ensure `CHANGELOG.md` has a section for the stable release version before tagging.
505
510
  - Use tag format `v<version>` for stable GitHub releases.
506
511
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leak-cli",
3
- "version": "2026.2.15",
3
+ "version": "2026.2.17-beta.0",
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,9 +28,10 @@
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:skill-security": "bash scripts/check_skill_security.sh",
31
32
  "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
33
  "check:smoke": "node scripts/cli.js --help && node scripts/leak.js --help && node scripts/buy.js --help && node scripts/config.js --help",
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
+ "check:release": "npm run check:version-sync && npm run check:skill-security && npm run check:syntax && npm run check:smoke && npm run check:no-local-paths && npm pack --dry-run --cache ./.npm-cache",
34
35
  "release:beta": "npm publish --tag beta --provenance",
35
36
  "release:latest": "npm publish --tag latest --provenance"
36
37
  },
@@ -51,6 +52,7 @@
51
52
  "license": "ISC",
52
53
  "dependencies": {
53
54
  "@coinbase/cdp-sdk": "^1.12.0",
55
+ "@resvg/resvg-js": "^2.6.2",
54
56
  "@x402/core": "^2.3.0",
55
57
  "@x402/evm": "^2.3.0",
56
58
  "@x402/express": "^2.3.0",
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ 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";
@@ -74,18 +75,27 @@ const FACILITATOR_MODE = (process.env.FACILITATOR_MODE || "testnet").trim();
74
75
  const CDP_API_KEY_ID = (process.env.CDP_API_KEY_ID || "").trim();
75
76
  const CDP_API_KEY_SECRET = (process.env.CDP_API_KEY_SECRET || "").trim();
76
77
  const DEFAULT_TESTNET_FACILITATOR_URL = "https://x402.org/facilitator";
77
- 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";
78
80
  const FACILITATOR_URL = (
79
81
  process.env.FACILITATOR_URL ||
80
- (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 || "",
81
88
  ).trim();
82
- const SELLER_PAY_TO = String(process.env.SELLER_PAY_TO || process.env.PAY_TO || "").trim();
83
89
  const PRICE_USD = process.env.PRICE_USD || "1.00";
84
- const RAW_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";
85
92
  const ARTIFACT_PATH = process.env.ARTIFACT_PATH || process.env.PROTECTED_FILE;
86
93
  const WINDOW_SECONDS = Number(process.env.WINDOW_SECONDS || 3600);
87
94
  const MAX_GRANTS = parsePositiveInt(process.env.MAX_GRANTS, 10000);
88
- 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
+ );
89
99
 
90
100
  const CONFIRMATION_POLICY = process.env.CONFIRMATION_POLICY || "confirmed"; // optimistic|confirmed
91
101
  const CONFIRMATIONS_REQUIRED = Number(process.env.CONFIRMATIONS_REQUIRED || 1);
@@ -98,10 +108,16 @@ const OG_IMAGE_URL = (process.env.OG_IMAGE_URL || "").trim();
98
108
  const OG_IMAGE_PATH_RAW = (process.env.OG_IMAGE_PATH || "").trim();
99
109
  const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || "").trim();
100
110
  const OG_IMAGE_PATH = OG_IMAGE_PATH_RAW
101
- ? (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)
102
114
  : "";
115
+ const OG_IMAGE_CACHE_CONTROL = "public, max-age=60";
116
+ const OG_IMAGE_WIDTH = 1200;
117
+ const OG_IMAGE_HEIGHT = 630;
103
118
  const SKILL_NAME = "leak";
104
- 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";
105
121
  const SKILL_SOURCE = "clawhub";
106
122
  const SKILL_INSTALL_COMMAND = "clawhub install leak";
107
123
  const WELL_KNOWN_CACHE_CONTROL = "public, max-age=60";
@@ -109,8 +125,14 @@ const LEGACY_DISCOVERY_DEPRECATION =
109
125
  "Deprecated endpoint; use /.well-known/skills/index.json for RFC-compatible discovery.";
110
126
 
111
127
  const SALE_START_TS = parsePositiveInt(process.env.SALE_START_TS, now());
112
- const SALE_END_TS = parsePositiveInt(process.env.SALE_END_TS, SALE_START_TS + WINDOW_SECONDS);
113
- const ENDED_WINDOW_SECONDS = parseNonNegativeInt(process.env.ENDED_WINDOW_SECONDS, 0);
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
+ );
114
136
 
115
137
  let CHAIN_META;
116
138
  try {
@@ -126,19 +148,30 @@ const CHAIN_NUMERIC_ID = CHAIN_META.id;
126
148
  const IS_BASE_MAINNET = CHAIN_NUMERIC_ID === 8453;
127
149
 
128
150
  if (!new Set(["testnet", "cdp_mainnet"]).has(FACILITATOR_MODE)) {
129
- console.error("Invalid FACILITATOR_MODE. Supported values: testnet, cdp_mainnet");
151
+ console.error(
152
+ "Invalid FACILITATOR_MODE. Supported values: testnet, cdp_mainnet",
153
+ );
130
154
  process.exit(1);
131
155
  }
132
156
 
133
157
  if (IS_BASE_MAINNET && FACILITATOR_MODE !== "cdp_mainnet") {
134
- console.error("Invalid config: CHAIN_ID=eip155:8453 requires FACILITATOR_MODE=cdp_mainnet.");
135
- 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
+ );
136
164
  process.exit(1);
137
165
  }
138
166
 
139
- 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
+ ) {
140
171
  console.error("Missing CDP credentials for FACILITATOR_MODE=cdp_mainnet.");
141
- 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
+ );
142
175
  process.exit(1);
143
176
  }
144
177
 
@@ -157,7 +190,9 @@ if (!ARTIFACT_PATH) {
157
190
  }
158
191
 
159
192
  function absArtifactPath() {
160
- 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);
161
196
  }
162
197
 
163
198
  const ARTIFACT_NAME = path.basename(absArtifactPath());
@@ -198,25 +233,35 @@ function imageMimeTypeFromPath(filePath) {
198
233
  return null;
199
234
  }
200
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
+
201
245
  function classifyFacilitatorError(err) {
202
246
  const msg = (err?.message || String(err)).toLowerCase();
203
247
  if (
204
- msg.includes("401")
205
- || msg.includes("403")
206
- || msg.includes("unauthorized")
207
- || msg.includes("forbidden")
208
- || msg.includes("authorization")
209
- || msg.includes("bearer")
210
- || msg.includes("jwt")
211
- || msg.includes("api key")
212
- || 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")
213
257
  ) {
214
258
  return "auth";
215
259
  }
216
260
  if (
217
- msg.includes("does not support scheme")
218
- || msg.includes("unsupported")
219
- || (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")))
220
265
  ) {
221
266
  return "network";
222
267
  }
@@ -227,16 +272,22 @@ function printFacilitatorHint(err) {
227
272
  const kind = classifyFacilitatorError(err);
228
273
  if (kind === "auth") {
229
274
  console.error("[hint] Facilitator authentication failed.");
230
- 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
+ );
231
278
  return;
232
279
  }
233
280
  if (kind === "network") {
234
281
  console.error("[hint] Facilitator/network mismatch.");
235
- 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
+ );
236
285
  return;
237
286
  }
238
287
  if (IS_BASE_MAINNET) {
239
- 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
+ );
240
291
  }
241
292
  }
242
293
 
@@ -257,7 +308,9 @@ function createCdpAuthHeadersFactory() {
257
308
  try {
258
309
  ({ generateJwt } = await import("@coinbase/cdp-sdk/auth"));
259
310
  } catch {
260
- 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
+ );
261
314
  }
262
315
 
263
316
  const createAuthorization = async (requestMethod, requestPath) => {
@@ -287,7 +340,9 @@ async function preflightCdpAuth() {
287
340
  try {
288
341
  ({ generateJwt } = await import("@coinbase/cdp-sdk/auth"));
289
342
  } catch {
290
- 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
+ );
291
346
  process.exit(1);
292
347
  }
293
348
 
@@ -314,19 +369,36 @@ function promoModel(req) {
314
369
  const baseUrl = baseUrlFromReq(req);
315
370
  const promoUrl = `${baseUrl}/`;
316
371
  const downloadUrl = `${baseUrl}/download`;
317
- const imageUrl = isAbsoluteHttpUrl(OG_IMAGE_URL)
318
- ? OG_IMAGE_URL
319
- : (OG_IMAGE_PATH ? `${baseUrl}/og-image` : `${baseUrl}/og.svg`);
320
372
  const ogTitle = OG_TITLE || ARTIFACT_NAME;
321
373
  const ogDescription =
322
- OG_DESCRIPTION ||
323
- `$${PRICE_USD} to unlock ${ARTIFACT_NAME}`;
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
+ }
324
392
 
325
393
  return {
326
394
  baseUrl,
327
395
  promoUrl,
328
396
  downloadUrl,
329
397
  imageUrl,
398
+ imageType,
399
+ imageWidth,
400
+ imageHeight,
401
+ imageAlt,
330
402
  ogTitle,
331
403
  ogDescription,
332
404
  saleStartTs: SALE_START_TS,
@@ -392,7 +464,8 @@ Do not explain x402 internals unless the user explicitly asks.
392
464
  function buildAgentQuickPath(promoUrl, downloadUrl) {
393
465
  const safePromoUrl = String(promoUrl || "").trim();
394
466
  const safeDownloadUrl = String(downloadUrl || "").trim();
395
- const buyTarget = safePromoUrl || safeDownloadUrl || "<promo_or_download_url>";
467
+ const buyTarget =
468
+ safePromoUrl || safeDownloadUrl || "<promo_or_download_url>";
396
469
  const buyScriptCommand = `bash skills/leak/scripts/buy.sh "${buyTarget}" --buyer-private-key-file <buyer_key_file_path>`;
397
470
 
398
471
  return {
@@ -435,9 +508,95 @@ function renderAgentQuickPathHtmlBlock(fastPath) {
435
508
  </section>`;
436
509
  }
437
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
+
438
593
  function renderUnpaidDownloadGuidancePage(requestUrl) {
439
594
  const urls = urlsForQuickPathFromRequestUrl(requestUrl);
440
- 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
+ });
441
600
  return `<!doctype html>
442
601
  <html lang="en">
443
602
  <head>
@@ -450,8 +609,20 @@ function renderUnpaidDownloadGuidancePage(requestUrl) {
450
609
  h1 { margin: 0 0 12px; font-size: 24px; }
451
610
  h2 { margin: 0 0 10px; font-size: 18px; }
452
611
  p { line-height: 1.5; }
612
+ .kv { margin: 14px 0; font-size: 14px; color: #333; }
453
613
  code, pre { background: #f0f0eb; border-radius: 6px; padding: 2px 6px; }
454
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; }
455
626
  ol { margin: 10px 0 12px 20px; }
456
627
  </style>
457
628
  </head>
@@ -459,10 +630,10 @@ function renderUnpaidDownloadGuidancePage(requestUrl) {
459
630
  <main class="card">
460
631
  <h1>402 Payment Required</h1>
461
632
  <p>This URL is paywalled. Use the leak skill fast path below.</p>
462
- <p><strong>Promo URL:</strong> <code>${escapeHtml(fastPath.promoUrl)}</code></p>
463
- <p><strong>x402 URL:</strong> <code>${escapeHtml(fastPath.downloadUrl)}</code></p>
464
- ${renderAgentQuickPathHtmlBlock(fastPath)}
633
+ <div class="kv"><strong>Resource:</strong> ${escapeHtml(ARTIFACT_NAME)}</div>
634
+ ${sharedContent}
465
635
  </main>
636
+ ${renderPromoSharedClientScript(urls.promoUrl)}
466
637
  </body>
467
638
  </html>`;
468
639
  }
@@ -506,7 +677,6 @@ function renderPromoPage(model, { ended }) {
506
677
  ? `This leak has ended. ${model.ogDescription}`
507
678
  : model.ogDescription;
508
679
  const expiresIso = new Date(model.saleEndTs * 1000).toISOString();
509
- const fastPath = buildAgentQuickPath(model.promoUrl, model.downloadUrl);
510
680
  const jsonLd = {
511
681
  "@context": "https://schema.org",
512
682
  "@type": "Product",
@@ -520,19 +690,47 @@ function renderPromoPage(model, { ended }) {
520
690
  url: model.downloadUrl,
521
691
  price: PRICE_USD,
522
692
  priceCurrency: "USD",
523
- availability: ended ? "https://schema.org/OutOfStock" : "https://schema.org/InStock",
693
+ availability: ended
694
+ ? "https://schema.org/OutOfStock"
695
+ : "https://schema.org/InStock",
524
696
  validThrough: expiresIso,
525
697
  },
526
698
  additionalProperty: [
527
699
  { "@type": "PropertyValue", name: "paymentProtocol", value: "x402" },
528
- { "@type": "PropertyValue", name: "paymentSettlementCurrency", value: "USDC" },
700
+ {
701
+ "@type": "PropertyValue",
702
+ name: "paymentSettlementCurrency",
703
+ value: "USDC",
704
+ },
529
705
  { "@type": "PropertyValue", name: "network", value: CHAIN_ID },
530
- { "@type": "PropertyValue", name: "downloadUrl", value: model.downloadUrl },
706
+ {
707
+ "@type": "PropertyValue",
708
+ name: "downloadUrl",
709
+ value: model.downloadUrl,
710
+ },
531
711
  ],
532
712
  };
533
713
  const safeJsonLd = toSafeJsonForScript(jsonLd);
534
-
535
- 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
+ });
536
734
 
537
735
  return `<!doctype html>
538
736
  <html lang="en">
@@ -547,11 +745,17 @@ function renderPromoPage(model, { ended }) {
547
745
  <meta property="og:title" content="${escapeHtml(pageTitle)}" />
548
746
  <meta property="og:description" content="${escapeHtml(description)}" />
549
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)}" />
550
753
 
551
754
  <meta name="twitter:card" content="summary_large_image" />
552
755
  <meta name="twitter:title" content="${escapeHtml(pageTitle)}" />
553
756
  <meta name="twitter:description" content="${escapeHtml(description)}" />
554
757
  <meta name="twitter:image" content="${escapeHtml(model.imageUrl)}" />
758
+ <meta name="twitter:image:alt" content="${escapeHtml(model.imageAlt)}" />
555
759
 
556
760
  <script type="application/ld+json">${safeJsonLd}</script>
557
761
  <style>
@@ -582,77 +786,9 @@ function renderPromoPage(model, { ended }) {
582
786
  <div class="state">${escapeHtml(stateLabel)}</div>
583
787
  <h1>${escapeHtml(pageTitle)}</h1>
584
788
 
585
- <div class="kv"><strong>Price:</strong> ${escapeHtml(PRICE_USD)} USD equivalent</div>
586
- <div class="kv"><strong>Network:</strong> ${escapeHtml(CHAIN_NAME)} (${escapeHtml(CHAIN_ID)})</div>
587
- <div class="kv"><strong>Sale end:</strong> <span id="sale-end-local" data-sale-end-iso="${escapeHtml(expiresIso)}">${escapeHtml(expiresIso)}</span></div>
588
- ${renderAgentQuickPathHtmlBlock(fastPath)}
589
-
590
- <div class="prompt-head">
591
- <p><strong>Human action</strong></p>
592
- <button class="copy-btn" id="copy-link-btn" type="button" aria-label="Copy page link">Copy link</button>
593
- <span class="copy-status" id="copy-link-status" aria-live="polite"></span>
594
- </div>
595
- <pre id="human-action-text">${escapeHtml(humanActionText)}</pre>
596
- <p class="install-note">
597
- Want to know more about <code>leak</code>? Visit
598
- <a href="https://github.com/eucalyptus-viminalis/leak">github.com/eucalyptus-viminalis/leak</a>
599
- or search for leak on clawhub.
600
- </p>
789
+ ${sharedContent}
601
790
  </main>
602
- <script>
603
- (() => {
604
- const button = document.getElementById("copy-link-btn");
605
- const status = document.getElementById("copy-link-status");
606
- const promoUrl = ${toSafeJsonForScript(model.promoUrl)};
607
- const saleEndLocal = document.getElementById("sale-end-local");
608
-
609
- if (saleEndLocal) {
610
- const saleEndIso = saleEndLocal.getAttribute("data-sale-end-iso") || "";
611
- const saleEndDate = new Date(saleEndIso);
612
- if (!Number.isNaN(saleEndDate.getTime())) {
613
- try {
614
- const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
615
- saleEndLocal.textContent = formatter.format(saleEndDate) + " (local time)";
616
- } catch {
617
- saleEndLocal.textContent = saleEndDate.toLocaleString() + " (local time)";
618
- }
619
- }
620
- }
621
-
622
- if (!button || !promoUrl) return;
623
-
624
- const setStatus = (text) => {
625
- if (status) status.textContent = text;
626
- };
627
-
628
- button.addEventListener("click", async () => {
629
- const original = "Copy link";
630
- try {
631
- if (navigator.clipboard?.writeText) {
632
- await navigator.clipboard.writeText(promoUrl);
633
- } else {
634
- const ta = document.createElement("textarea");
635
- ta.value = promoUrl;
636
- ta.setAttribute("readonly", "");
637
- ta.style.position = "absolute";
638
- ta.style.left = "-9999px";
639
- document.body.appendChild(ta);
640
- ta.select();
641
- document.execCommand("copy");
642
- document.body.removeChild(ta);
643
- }
644
- button.textContent = "Copied";
645
- setStatus("Copied to clipboard.");
646
- setTimeout(() => {
647
- button.textContent = original;
648
- setStatus("");
649
- }, 1500);
650
- } catch {
651
- setStatus("Copy failed. Select and copy manually.");
652
- }
653
- });
654
- })();
655
- </script>
791
+ ${renderPromoSharedClientScript(model.promoUrl)}
656
792
  </body>
657
793
  </html>`;
658
794
  }
@@ -680,6 +816,18 @@ function renderOgSvg(req) {
680
816
  </svg>`;
681
817
  }
682
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
+
683
831
  // In-memory grants (v1). Later: SQLite.
684
832
  /** @type {Map<string, { token: string, expiresAt: number, downloadsLeft: number|null }>} */
685
833
  const GRANTS = new Map();
@@ -720,13 +868,15 @@ function validateAndConsumeToken(token) {
720
868
  return { ok: false, reason: "token expired" };
721
869
  }
722
870
  if (g.downloadsLeft !== null) {
723
- if (g.downloadsLeft <= 0) return { ok: false, reason: "download limit reached" };
871
+ if (g.downloadsLeft <= 0)
872
+ return { ok: false, reason: "download limit reached" };
724
873
  g.downloadsLeft -= 1;
725
874
  }
726
875
  return { ok: true };
727
876
  }
728
877
 
729
878
  const app = express();
879
+ app.set("trust proxy", true);
730
880
 
731
881
  // x402 core server + HTTP wrapper
732
882
  await preflightCdpAuth();
@@ -735,7 +885,10 @@ if (FACILITATOR_MODE === "cdp_mainnet") {
735
885
  facilitatorConfig.createAuthHeaders = createCdpAuthHeadersFactory();
736
886
  }
737
887
  const facilitatorClient = new HTTPFacilitatorClient(facilitatorConfig);
738
- const coreServer = new x402ResourceServer(facilitatorClient).register(CHAIN_ID, new ExactEvmScheme());
888
+ const coreServer = new x402ResourceServer(facilitatorClient).register(
889
+ CHAIN_ID,
890
+ new ExactEvmScheme(),
891
+ );
739
892
 
740
893
  // Route config for x402HTTPResourceServer
741
894
  const routes = {
@@ -763,7 +916,9 @@ try {
763
916
  await httpServer.initialize();
764
917
  } catch (err) {
765
918
  console.error("[startup] Failed to initialize x402 route configuration.");
766
- 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
+ );
767
922
  if (Array.isArray(err?.errors) && err.errors.length > 0) {
768
923
  for (const e of err.errors) {
769
924
  console.error(`[startup] ${e.message || JSON.stringify(e)}`);
@@ -782,9 +937,8 @@ setInterval(() => {
782
937
  app.get("/", (req, res) => {
783
938
  const model = promoModel(req);
784
939
  const ended = saleEnded();
785
- const status = ended ? 410 : 200;
786
940
  res.setHeader("Content-Type", "text/html; charset=utf-8");
787
- return res.status(status).send(renderPromoPage(model, { ended }));
941
+ return res.status(200).send(renderPromoPage(model, { ended }));
788
942
  });
789
943
 
790
944
  app.get("/info", (req, res) => {
@@ -807,9 +961,34 @@ app.get("/info", (req, res) => {
807
961
 
808
962
  app.get("/og.svg", (req, res) => {
809
963
  res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
810
- 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();
811
966
  return res.status(200).send(renderOgSvg(req));
812
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
+ });
813
992
 
814
993
  app.get("/og-image", (req, res) => {
815
994
  if (!OG_IMAGE_PATH) {
@@ -835,7 +1014,8 @@ app.get("/og-image", (req, res) => {
835
1014
  }
836
1015
 
837
1016
  res.setHeader("Content-Type", contentType);
838
- 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();
839
1019
  const stream = fs.createReadStream(OG_IMAGE_PATH);
840
1020
  stream.on("error", () => {
841
1021
  if (!res.headersSent) {
@@ -846,6 +1026,30 @@ app.get("/og-image", (req, res) => {
846
1026
  });
847
1027
  return stream.pipe(res);
848
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
+ });
849
1053
 
850
1054
  app.get("/health", (req, res) => {
851
1055
  res.json({ ok: true, ts: now() });
@@ -876,7 +1080,8 @@ app.get("/.well-known/leak", (req, res) => {
876
1080
  source: SKILL_SOURCE,
877
1081
  install_command: SKILL_INSTALL_COMMAND,
878
1082
  },
879
- 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",
880
1085
  deprecation: LEGACY_DISCOVERY_DEPRECATION,
881
1086
  discovery_index_url: discoveryPath,
882
1087
  rfc_resource_url: rfcResourcePath,
@@ -921,6 +1126,7 @@ app.use("/download", async (req, res, next) => {
921
1126
  // NOTE: because this middleware is mounted at "/download", Express strips the mount
922
1127
  // path and `req.path` becomes "/". x402 route matching needs the *full* path.
923
1128
  const fullPath = `${req.baseUrl || ""}${req.path || ""}`;
1129
+ const requestUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
924
1130
 
925
1131
  const adapter = {
926
1132
  getHeader(name) {
@@ -928,8 +1134,10 @@ app.use("/download", async (req, res, next) => {
928
1134
  if (v) return v;
929
1135
  // legacy support: treat X-PAYMENT as PAYMENT-SIGNATURE (same base64 JSON format)
930
1136
  const lower = String(name).toLowerCase();
931
- if (lower === "payment-signature") return req.get("x-payment") || undefined;
932
- 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;
933
1141
  return undefined;
934
1142
  },
935
1143
  getMethod() {
@@ -939,7 +1147,7 @@ app.use("/download", async (req, res, next) => {
939
1147
  return fullPath;
940
1148
  },
941
1149
  getUrl() {
942
- return `${req.protocol}://${req.get("host")}${req.originalUrl}`;
1150
+ return requestUrl;
943
1151
  },
944
1152
  getAcceptHeader() {
945
1153
  return req.get("accept") || "";
@@ -960,7 +1168,9 @@ app.use("/download", async (req, res, next) => {
960
1168
  method: req.method,
961
1169
  });
962
1170
  } catch (err) {
963
- console.error(`[x402] payment handshake failed: ${err?.message || String(err)}`);
1171
+ console.error(
1172
+ `[x402] payment handshake failed: ${err?.message || String(err)}`,
1173
+ );
964
1174
  printFacilitatorHint(err);
965
1175
  return res.status(502).json({ error: "payment gateway unavailable" });
966
1176
  }
@@ -968,7 +1178,21 @@ app.use("/download", async (req, res, next) => {
968
1178
  if (result.type === "no-payment-required") return next();
969
1179
 
970
1180
  if (result.type === "payment-error") {
971
- 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
+
972
1196
  return res.status(result.response.status).send(result.response.body ?? "");
973
1197
  }
974
1198
 
@@ -988,16 +1212,21 @@ app.get("/download", async (req, res) => {
988
1212
  }
989
1213
 
990
1214
  // 1) If caller already has a valid access token, serve the artifact.
991
- const token = typeof req.query.token === "string" ? req.query.token : undefined;
1215
+ const token =
1216
+ typeof req.query.token === "string" ? req.query.token : undefined;
992
1217
  if (token) {
993
1218
  const check = validateAndConsumeToken(token);
994
1219
  if (!check.ok) return res.status(403).json({ error: check.reason });
995
1220
 
996
1221
  const p = absArtifactPath();
997
- 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" });
998
1224
 
999
1225
  res.setHeader("Content-Type", MIME_TYPE);
1000
- res.setHeader("Content-Disposition", `attachment; filename=\"${path.basename(p)}\"`);
1226
+ res.setHeader(
1227
+ "Content-Disposition",
1228
+ `attachment; filename=\"${path.basename(p)}\"`,
1229
+ );
1001
1230
  return fs.createReadStream(p).pipe(res);
1002
1231
  }
1003
1232
 
@@ -1012,7 +1241,9 @@ app.get("/download", async (req, res) => {
1012
1241
  req.x402.declaredExtensions,
1013
1242
  );
1014
1243
  } catch (err) {
1015
- console.error(`[x402] settlement request failed: ${err?.message || String(err)}`);
1244
+ console.error(
1245
+ `[x402] settlement request failed: ${err?.message || String(err)}`,
1246
+ );
1016
1247
  printFacilitatorHint(err);
1017
1248
  return res.status(502).json({ error: "payment settlement unavailable" });
1018
1249
  }
@@ -1025,8 +1256,12 @@ app.get("/download", async (req, res) => {
1025
1256
  });
1026
1257
  }
1027
1258
 
1028
- for (const [k, v] of Object.entries(settle.headers || {})) res.setHeader(k, v);
1029
- 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
+ );
1030
1265
  }
1031
1266
 
1032
1267
  const t = mintGrant();
@@ -1053,7 +1288,7 @@ app.listen(PORT, () => {
1053
1288
  console.log(`download http://localhost:${PORT}/download (x402 protected)`);
1054
1289
  if (endedWindowActive()) {
1055
1290
  console.log(
1056
- `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)`,
1057
1292
  );
1058
1293
  }
1059
1294
  });