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.
- package/README.md +18 -13
- package/package.json +4 -2
- 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
|
|
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>
|
|
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
|
-
|
|
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
|
|
111
|
-
-
|
|
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
|
|
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
|
-
- `
|
|
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
|
|
|
@@ -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.
|
|
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 =
|
|
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"
|
|
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 =
|
|
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(
|
|
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
|
-
?
|
|
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 =
|
|
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(
|
|
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
|
+
);
|
|
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(
|
|
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(
|
|
135
|
-
|
|
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 (
|
|
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(
|
|
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)
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
<
|
|
463
|
-
|
|
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
|
|
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
|
-
{
|
|
700
|
+
{
|
|
701
|
+
"@type": "PropertyValue",
|
|
702
|
+
name: "paymentSettlementCurrency",
|
|
703
|
+
value: "USDC",
|
|
704
|
+
},
|
|
529
705
|
{ "@type": "PropertyValue", name: "network", value: CHAIN_ID },
|
|
530
|
-
{
|
|
706
|
+
{
|
|
707
|
+
"@type": "PropertyValue",
|
|
708
|
+
name: "downloadUrl",
|
|
709
|
+
value: model.downloadUrl,
|
|
710
|
+
},
|
|
531
711
|
],
|
|
532
712
|
};
|
|
533
713
|
const safeJsonLd = toSafeJsonForScript(jsonLd);
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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(
|
|
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(
|
|
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",
|
|
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",
|
|
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:
|
|
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")
|
|
932
|
-
|
|
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
|
|
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(
|
|
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 || {}))
|
|
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 =
|
|
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))
|
|
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(
|
|
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(
|
|
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 || {}))
|
|
1029
|
-
|
|
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
|
});
|