@bolloon/bolloon-agent 0.5.2 → 0.5.3
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 +4 -4
- package/dist/agents/chain/transfer-classify.js +99 -0
- package/dist/agents/chain/transfer-index.js +354 -0
- package/dist/agents/identity/address-binding.js +613 -0
- package/dist/agents/network-pulse.js +706 -27
- package/dist/agents/x402/direct-payment.js +689 -0
- package/dist/agents/x402/order-identity.js +408 -0
- package/dist/agents/x402/paid-info-store.js +17 -0
- package/dist/agents/x402/seller-signing.js +614 -0
- package/dist/agents/x402/seller-summary.js +398 -0
- package/dist/cli/identity-command.js +327 -2
- package/dist/cli/protocol-envelope.js +4 -0
- package/dist/cli/update-commands.js +41 -25
- package/dist/cli/x402-buyer-command.js +463 -0
- package/dist/cli/x402-seller-command.js +289 -0
- package/dist/cli-entry.js +21 -5
- package/dist/electron-build/agents/chain/transfer-classify.js +106 -0
- package/dist/electron-build/agents/chain/transfer-classify.js.map +1 -0
- package/dist/electron-build/agents/chain/transfer-index.js +400 -0
- package/dist/electron-build/agents/chain/transfer-index.js.map +1 -0
- package/dist/electron-build/agents/identity/address-binding.js +672 -0
- package/dist/electron-build/agents/identity/address-binding.js.map +1 -0
- package/dist/electron-build/agents/network-pulse.js +714 -28
- package/dist/electron-build/agents/network-pulse.js.map +1 -1
- package/dist/electron-build/agents/x402/direct-payment.js +744 -0
- package/dist/electron-build/agents/x402/direct-payment.js.map +1 -0
- package/dist/electron-build/agents/x402/order-identity.js +458 -0
- package/dist/electron-build/agents/x402/order-identity.js.map +1 -0
- package/dist/electron-build/agents/x402/paid-info-protocol.js.map +1 -1
- package/dist/electron-build/agents/x402/paid-info-store.js +18 -0
- package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
- package/dist/electron-build/agents/x402/seller-signing.js +682 -0
- package/dist/electron-build/agents/x402/seller-signing.js.map +1 -0
- package/dist/electron-build/utils/auto-update.js +2 -2
- package/dist/electron-build/utils/auto-update.js.map +1 -1
- package/dist/electron-build/utils/runtime-bootstrap.js +1 -1
- package/dist/electron-build/utils/runtime-bootstrap.js.map +1 -1
- package/dist/electron-build/utils/update-manager.js +3 -3
- package/dist/electron-build/utils/update-manager.js.map +1 -1
- package/dist/electron-build/utils/version-identity.js +1 -1
- package/dist/electron-build/utils/version-identity.js.map +1 -1
- package/dist/electron-build/web/routes-x402-info.js +28 -1
- package/dist/electron-build/web/routes-x402-info.js.map +1 -1
- package/dist/index.js +3 -3
- package/dist/utils/auto-update.js +2 -2
- package/dist/utils/runtime-bootstrap.js +1 -1
- package/dist/utils/update-health.js +2 -2
- package/dist/utils/update-manager.js +3 -3
- package/dist/utils/version-identity.js +1 -1
- package/dist/web/bolloon-web.json +3 -3
- package/dist/web/routes-x402-info.js +28 -1
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* seller-signing.ts — 卖方本机签名交付 (bolloon-x402-seller/1)
|
|
4
|
+
*
|
|
5
|
+
* 一句话: **私钥只在卖方本机**, 服务器只持卖方 DIAP **公钥**。
|
|
6
|
+
*
|
|
7
|
+
* 为什么需要这一层(而不是让服务器签):
|
|
8
|
+
* 付费信息端点 (pay.bolloon.cn) 跑在别人的机器/云上。把 DIAP 私钥放上去 = 把
|
|
9
|
+
* "卖方身份" 交给部署方; 一旦服务器被拿到, 攻击者能签出**任何**内容的信封
|
|
10
|
+
* (签名是身份, 不是内容)。所以 ECS 上**没有**私钥 —— 即使付款校验通过, 它也
|
|
11
|
+
* 只能回 `202 已付款待签名`, 等本机签完再交付。
|
|
12
|
+
*
|
|
13
|
+
* 链路 (每一步都有机器可读的事实):
|
|
14
|
+
* 买方 → GET /api/x402/info/:id (无付款头) → 402 + accepts [既有行为, 一字不改]
|
|
15
|
+
* 买方 → 带 X-PAYMENT 再来 → 服务器结算 → 落一条 pending (待办)
|
|
16
|
+
* → 回 202 { status: 'paid_awaiting_signature', pendingId }
|
|
17
|
+
* 本机 → bolloon x402 pending list (HMAC 认证) → 看到待办
|
|
18
|
+
* 本机 → 人/智能体确认 (show 展示付款凭据/内容哈希/来源)
|
|
19
|
+
* 本机 → bolloon x402 pending sign <id> → **复用既有 ed25519Sign** 签信封
|
|
20
|
+
* → POST 回服务器 → 服务器用**卖方公钥**验签后才收下
|
|
21
|
+
* 买方 → 带**同一张** X-PAYMENT 再来 → 200 + 已签名信封 (按 receiptHash 取回)
|
|
22
|
+
* → 买方离线验签 (ed25519Verify / verifyEnvelope)
|
|
23
|
+
*
|
|
24
|
+
* 认证: 0600 共享密钥文件 + HMAC-SHA256(含时间戳与 nonce) —— 防重放见 §auth。
|
|
25
|
+
* 幂等: 同一 receiptHash 只会有**一条** pending; 一旦签过, 再拿同一张凭据来取
|
|
26
|
+
* 永远拿同一个信封 (不会重复签、不会重复交付)。
|
|
27
|
+
*
|
|
28
|
+
* 诚实口径 (不许美化):
|
|
29
|
+
* · 卖方不在线 ⇒ 买方**只能**拿到 "已付款待签名" (202), **拿不到信封** ——
|
|
30
|
+
* 这条路径上的钱已经收了, 交付是异步的, 文档必须这么讲。
|
|
31
|
+
* · 服务器没配共享密钥 ⇒ 待办端点一律拒绝 (403), **不是** "没有待办"。
|
|
32
|
+
* · 本机没有该 item 的内容 / 内容哈希对不上 ⇒ **拒签** (绝不签一个对不上的信封)。
|
|
33
|
+
*/
|
|
34
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
35
|
+
if (k2 === undefined) k2 = k;
|
|
36
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
37
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
38
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
39
|
+
}
|
|
40
|
+
Object.defineProperty(o, k2, desc);
|
|
41
|
+
}) : (function(o, m, k, k2) {
|
|
42
|
+
if (k2 === undefined) k2 = k;
|
|
43
|
+
o[k2] = m[k];
|
|
44
|
+
}));
|
|
45
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
46
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
47
|
+
}) : function(o, v) {
|
|
48
|
+
o["default"] = v;
|
|
49
|
+
});
|
|
50
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
51
|
+
var ownKeys = function(o) {
|
|
52
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
53
|
+
var ar = [];
|
|
54
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
55
|
+
return ar;
|
|
56
|
+
};
|
|
57
|
+
return ownKeys(o);
|
|
58
|
+
};
|
|
59
|
+
return function (mod) {
|
|
60
|
+
if (mod && mod.__esModule) return mod;
|
|
61
|
+
var result = {};
|
|
62
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
63
|
+
__setModuleDefault(result, mod);
|
|
64
|
+
return result;
|
|
65
|
+
};
|
|
66
|
+
})();
|
|
67
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
68
|
+
exports.NonceLedger = exports.SELLER_API_PREFIX = exports.SELLER_KEY_FILE = exports.SELLER_PENDING_DIRNAME = exports.SELLER_AUTH_FILE = exports.PENDING_TTL_MS = exports.AUTH_MAX_SKEW_MS = exports.SELLER_AUTH_PROTOCOL = exports.SELLER_PROTOCOL = void 0;
|
|
69
|
+
exports.sellerHome = sellerHome;
|
|
70
|
+
exports.sellerAuthPath = sellerAuthPath;
|
|
71
|
+
exports.sellerPendingDir = sellerPendingDir;
|
|
72
|
+
exports.sellerKeyPath = sellerKeyPath;
|
|
73
|
+
exports.generateSellerAuth = generateSellerAuth;
|
|
74
|
+
exports.saveSellerAuth = saveSellerAuth;
|
|
75
|
+
exports.loadSellerAuth = loadSellerAuth;
|
|
76
|
+
exports.sellerAuthFingerprint = sellerAuthFingerprint;
|
|
77
|
+
exports.authCanonicalString = authCanonicalString;
|
|
78
|
+
exports.computeAuthSignature = computeAuthSignature;
|
|
79
|
+
exports.signSellerRequest = signSellerRequest;
|
|
80
|
+
exports.verifySellerAuth = verifySellerAuth;
|
|
81
|
+
exports.newPendingId = newPendingId;
|
|
82
|
+
exports.readPending = readPending;
|
|
83
|
+
exports.writePending = writePending;
|
|
84
|
+
exports.listPending = listPending;
|
|
85
|
+
exports.pendingView = pendingView;
|
|
86
|
+
exports.findPendingByReceiptHash = findPendingByReceiptHash;
|
|
87
|
+
exports.getStoredEnvelope = getStoredEnvelope;
|
|
88
|
+
exports.storeEnvelope = storeEnvelope;
|
|
89
|
+
exports.enqueuePending = enqueuePending;
|
|
90
|
+
exports.resolvePendingDelivery = resolvePendingDelivery;
|
|
91
|
+
exports.resolvePaidDelivery = resolvePaidDelivery;
|
|
92
|
+
exports.acceptSignedEnvelope = acceptSignedEnvelope;
|
|
93
|
+
exports.sellerApiPaths = sellerApiPaths;
|
|
94
|
+
exports.paidInfoRetrievePath = paidInfoRetrievePath;
|
|
95
|
+
exports.handleSellerApi = handleSellerApi;
|
|
96
|
+
exports.sellerClientRequest = sellerClientRequest;
|
|
97
|
+
exports.signPending = signPending;
|
|
98
|
+
exports.resolveSellerKey = resolveSellerKey;
|
|
99
|
+
const fs = __importStar(require("fs/promises"));
|
|
100
|
+
const os = __importStar(require("os"));
|
|
101
|
+
const path = __importStar(require("path"));
|
|
102
|
+
const crypto = __importStar(require("crypto"));
|
|
103
|
+
const paid_info_protocol_js_1 = require("./paid-info-protocol.js");
|
|
104
|
+
// ────────────────────────────────────────────────────────────── 常量 / 协议
|
|
105
|
+
exports.SELLER_PROTOCOL = 'bolloon-x402-seller/1';
|
|
106
|
+
exports.SELLER_AUTH_PROTOCOL = 'bolloon-x402-seller-auth/1';
|
|
107
|
+
/** 时间戳允许偏移 (ms): 超出即拒 (时钟偏移不是重放, 但重放窗口就靠它兜底) */
|
|
108
|
+
exports.AUTH_MAX_SKEW_MS = 5 * 60 * 1000;
|
|
109
|
+
/** 待办默认存活 (超过就过期: 卖方长期不在线时不许无限期挂着 "可交付") */
|
|
110
|
+
exports.PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
111
|
+
exports.SELLER_AUTH_FILE = 'x402-seller-auth.json';
|
|
112
|
+
exports.SELLER_PENDING_DIRNAME = 'x402-seller-pending';
|
|
113
|
+
exports.SELLER_KEY_FILE = 'seller-key.json';
|
|
114
|
+
/** 队列端点前缀 —— nginx 只放行 `^~ /api/x402/`, 所以只能长在这下面 */
|
|
115
|
+
exports.SELLER_API_PREFIX = '/api/x402/seller';
|
|
116
|
+
// ────────────────────────────────────────────────────────────── 路径
|
|
117
|
+
/** 本机/服务端的 bolloon 主目录 (与 server.mjs 的 BOLLOON_HOME_DIR 同源) */
|
|
118
|
+
function sellerHome(explicit) {
|
|
119
|
+
return explicit || process.env.BOLLOON_SELLER_HOME || process.env.BOLLOON_HOME_DIR || os.homedir();
|
|
120
|
+
}
|
|
121
|
+
function sellerAuthPath(home) {
|
|
122
|
+
return path.join(sellerHome(home), '.bolloon', exports.SELLER_AUTH_FILE);
|
|
123
|
+
}
|
|
124
|
+
function sellerPendingDir(home) {
|
|
125
|
+
return path.join(sellerHome(home), '.bolloon', exports.SELLER_PENDING_DIRNAME);
|
|
126
|
+
}
|
|
127
|
+
/** 卖方公钥钉钉子文件 (只有公钥, 可以放服务器; 服务器**拿不到**私钥) */
|
|
128
|
+
function sellerKeyPath(home) {
|
|
129
|
+
return path.join(sellerHome(home), '.bolloon', exports.SELLER_KEY_FILE);
|
|
130
|
+
}
|
|
131
|
+
function safeId(id) {
|
|
132
|
+
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
133
|
+
}
|
|
134
|
+
/** 生成一把新的共享密钥 (不落盘; 落盘用 saveSellerAuth) */
|
|
135
|
+
function generateSellerAuth(endpoint, keyId) {
|
|
136
|
+
const secret = crypto.randomBytes(32).toString('base64');
|
|
137
|
+
const kid = keyId || `sk_${(0, paid_info_protocol_js_1.sha256Hex)(secret).slice(0, 12)}`;
|
|
138
|
+
return { protocol: exports.SELLER_AUTH_PROTOCOL, keyId: kid, secret, ...(endpoint ? { endpoint } : {}), createdAt: new Date().toISOString() };
|
|
139
|
+
}
|
|
140
|
+
/** 落盘: 目录 0700 / 文件 0600 (唯一允许的共享密钥落地通道) */
|
|
141
|
+
async function saveSellerAuth(cfg, home) {
|
|
142
|
+
const file = sellerAuthPath(home);
|
|
143
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
144
|
+
await fs.writeFile(file, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
145
|
+
await fs.chmod(file, 0o600).catch(() => undefined);
|
|
146
|
+
return file;
|
|
147
|
+
}
|
|
148
|
+
async function loadSellerAuth(home) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(await fs.readFile(sellerAuthPath(home), 'utf-8'));
|
|
151
|
+
if (!parsed?.keyId || !parsed?.secret)
|
|
152
|
+
return null;
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** 共享密钥指纹 (可打印): 用指纹说 "用的是哪把", 而不是打印密钥 */
|
|
160
|
+
function sellerAuthFingerprint(cfg) {
|
|
161
|
+
return `sha256:${(0, paid_info_protocol_js_1.sha256Hex)(cfg.secret).slice(0, 16)}`;
|
|
162
|
+
}
|
|
163
|
+
/** 被签的规范串 —— 方法/路径/时间戳/nonce/请求体哈希 五样缺一不可 */
|
|
164
|
+
function authCanonicalString(input) {
|
|
165
|
+
const bodyHash = (0, paid_info_protocol_js_1.sha256Hex)(String(input.body ?? ''));
|
|
166
|
+
return [String(input.method).toUpperCase(), input.path, String(input.ts), String(input.nonce), bodyHash].join('\n');
|
|
167
|
+
}
|
|
168
|
+
function computeAuthSignature(secretB64, input) {
|
|
169
|
+
const key = Buffer.from(secretB64, 'base64');
|
|
170
|
+
return crypto.createHmac('sha256', key).update(authCanonicalString(input), 'utf-8').digest('hex');
|
|
171
|
+
}
|
|
172
|
+
/** 客户端: 为一次请求生成认证头 (提交给 fetch 用) */
|
|
173
|
+
function signSellerRequest(cfg, req, now = Date.now()) {
|
|
174
|
+
const ts = String(now);
|
|
175
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
176
|
+
return {
|
|
177
|
+
'x-bolloon-seller-key': cfg.keyId,
|
|
178
|
+
'x-bolloon-seller-ts': ts,
|
|
179
|
+
'x-bolloon-seller-nonce': nonce,
|
|
180
|
+
'x-bolloon-seller-sig': computeAuthSignature(cfg.secret, { method: req.method, path: req.path, ts, nonce, body: req.body }),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** 服务端: 一次性 nonce 台账 (落盘, 重启后仍然记得 → 重启不能当"重放窗口刷新") */
|
|
184
|
+
class NonceLedger {
|
|
185
|
+
file;
|
|
186
|
+
seen = {};
|
|
187
|
+
loaded = false;
|
|
188
|
+
writeChain = Promise.resolve();
|
|
189
|
+
constructor(file) {
|
|
190
|
+
this.file = file;
|
|
191
|
+
}
|
|
192
|
+
async ensureLoaded() {
|
|
193
|
+
if (this.loaded)
|
|
194
|
+
return;
|
|
195
|
+
this.loaded = true;
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(await fs.readFile(this.file, 'utf-8'));
|
|
198
|
+
if (parsed && typeof parsed.seen === 'object')
|
|
199
|
+
this.seen = parsed.seen;
|
|
200
|
+
}
|
|
201
|
+
catch { /* 首次运行没有台账 = 空台账 */ }
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 记下 nonce。已见过 (或在窗口内见过) → false (重放)。
|
|
205
|
+
* 过期的 nonce 顺手清掉 (台账不会无限长)。
|
|
206
|
+
*/
|
|
207
|
+
async accept(nonce, ts, now = Date.now()) {
|
|
208
|
+
await this.ensureLoaded();
|
|
209
|
+
if (Object.prototype.hasOwnProperty.call(this.seen, nonce))
|
|
210
|
+
return false;
|
|
211
|
+
this.seen[nonce] = ts;
|
|
212
|
+
const cutoff = now - exports.AUTH_MAX_SKEW_MS;
|
|
213
|
+
for (const [n, t] of Object.entries(this.seen))
|
|
214
|
+
if (t < cutoff)
|
|
215
|
+
delete this.seen[n];
|
|
216
|
+
const snapshot = JSON.stringify({ seen: this.seen });
|
|
217
|
+
this.writeChain = this.writeChain
|
|
218
|
+
.then(async () => {
|
|
219
|
+
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
|
220
|
+
await fs.writeFile(this.file, snapshot, { mode: 0o600 });
|
|
221
|
+
})
|
|
222
|
+
.catch(() => undefined);
|
|
223
|
+
await this.writeChain;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
exports.NonceLedger = NonceLedger;
|
|
228
|
+
/**
|
|
229
|
+
* 服务端: 校验认证头。三道门一个都不能省:
|
|
230
|
+
* ① 签名 (HMAC-SHA256, timingSafeEqual 比较)
|
|
231
|
+
* ② 时间戳 (|now - ts| ≤ 5min)
|
|
232
|
+
* ③ 一次性 nonce (台账里没出现过) —— **必须在签名通过之后才记账**,
|
|
233
|
+
* 否则攻击者可以拿垃圾签名把好 nonce 耗掉 (拒绝服务)。
|
|
234
|
+
*/
|
|
235
|
+
async function verifySellerAuth(cfg, req, ledger, now = Date.now()) {
|
|
236
|
+
if (!cfg?.keyId || !cfg?.secret)
|
|
237
|
+
return { ok: false, code: 'SELLER_AUTH_NOT_CONFIGURED', detail: '本部署未配置卖方共享密钥 → 无权拉取待办' };
|
|
238
|
+
const h = req.headers;
|
|
239
|
+
const keyId = h['x-bolloon-seller-key'];
|
|
240
|
+
const ts = h['x-bolloon-seller-ts'];
|
|
241
|
+
const nonce = h['x-bolloon-seller-nonce'];
|
|
242
|
+
const sig = h['x-bolloon-seller-sig'];
|
|
243
|
+
if (!keyId || !ts || !nonce || !sig)
|
|
244
|
+
return { ok: false, code: 'SELLER_AUTH_REQUIRED', detail: '缺少 x-bolloon-seller-{key,ts,nonce,sig} 之一' };
|
|
245
|
+
if (keyId !== cfg.keyId)
|
|
246
|
+
return { ok: false, code: 'SELLER_AUTH_KEY_UNKNOWN', detail: `未知 keyId: ${String(keyId).slice(0, 32)}` };
|
|
247
|
+
const tsNum = Number(ts);
|
|
248
|
+
if (!Number.isFinite(tsNum))
|
|
249
|
+
return { ok: false, code: 'SELLER_AUTH_BAD_TS', detail: `时间戳不是数字: ${String(ts).slice(0, 32)}` };
|
|
250
|
+
const skew = Math.abs(now - tsNum);
|
|
251
|
+
if (skew > exports.AUTH_MAX_SKEW_MS)
|
|
252
|
+
return { ok: false, code: 'SELLER_AUTH_EXPIRED', detail: `时间戳偏移 ${Math.round(skew / 1000)}s 超出允许 ±${exports.AUTH_MAX_SKEW_MS / 1000}s` };
|
|
253
|
+
const expected = computeAuthSignature(cfg.secret, { method: req.method, path: req.path, ts: String(ts), nonce: String(nonce), body: req.body });
|
|
254
|
+
const a = Buffer.from(expected, 'hex');
|
|
255
|
+
const b = Buffer.from(String(sig), 'hex');
|
|
256
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b))
|
|
257
|
+
return { ok: false, code: 'SELLER_AUTH_SIGNATURE_INVALID', detail: 'HMAC 不匹配 (密钥不对, 或方法/路径/时间戳/nonce/请求体被改动)' };
|
|
258
|
+
const fresh = await ledger.accept(String(nonce), tsNum, now);
|
|
259
|
+
if (!fresh)
|
|
260
|
+
return { ok: false, code: 'SELLER_AUTH_REPLAY', detail: `nonce ${String(nonce).slice(0, 16)}… 已经用过 (一次性, 防重放)` };
|
|
261
|
+
return { ok: true, detail: `keyId=${cfg.keyId} nonce=${String(nonce).slice(0, 16)}…` };
|
|
262
|
+
}
|
|
263
|
+
function pendingFile(pendingId, home) {
|
|
264
|
+
return path.join(sellerPendingDir(home), `${safeId(pendingId)}.json`);
|
|
265
|
+
}
|
|
266
|
+
function envelopeFile(receiptHash, home) {
|
|
267
|
+
return path.join(sellerPendingDir(home), 'envelopes', `${safeId(receiptHash)}.json`);
|
|
268
|
+
}
|
|
269
|
+
function newPendingId(receiptHash) {
|
|
270
|
+
return `pnd_${(0, paid_info_protocol_js_1.sha256Hex)(receiptHash).slice(0, 16)}`;
|
|
271
|
+
}
|
|
272
|
+
/** 幂等键: 由 receiptHash 派生 pendingId ⇒ 同一张凭据**只可能**有一条待办 */
|
|
273
|
+
async function readPending(pendingId, home) {
|
|
274
|
+
try {
|
|
275
|
+
const parsed = JSON.parse(await fs.readFile(pendingFile(pendingId, home), 'utf-8'));
|
|
276
|
+
return parsed?.pendingId ? parsed : null;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function writePending(rec, home) {
|
|
283
|
+
const dir = sellerPendingDir(home);
|
|
284
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
285
|
+
const f = pendingFile(rec.pendingId, home);
|
|
286
|
+
const tmp = `${f}.tmp-${process.pid}`;
|
|
287
|
+
await fs.writeFile(tmp, JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
288
|
+
await fs.rename(tmp, f);
|
|
289
|
+
}
|
|
290
|
+
async function listPending(home) {
|
|
291
|
+
const dir = sellerPendingDir(home);
|
|
292
|
+
try {
|
|
293
|
+
const files = await fs.readdir(dir);
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const f of files) {
|
|
296
|
+
if (!f.endsWith('.json'))
|
|
297
|
+
continue;
|
|
298
|
+
try {
|
|
299
|
+
const parsed = JSON.parse(await fs.readFile(path.join(dir, f), 'utf-8'));
|
|
300
|
+
if (parsed?.pendingId)
|
|
301
|
+
out.push(parsed);
|
|
302
|
+
}
|
|
303
|
+
catch { /* 跳过坏文件 (不因一个坏文件看不到全部待办) */ }
|
|
304
|
+
}
|
|
305
|
+
return out.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** 过了 expiresAt 就如实标过期 (不改写文件, 读时判 —— 磁盘上的事实保持原样) */
|
|
312
|
+
function pendingView(rec, now = Date.now()) {
|
|
313
|
+
if (rec.status === 'awaiting_signature' && Date.parse(rec.expiresAt) < now)
|
|
314
|
+
return { ...rec, status: 'expired' };
|
|
315
|
+
return rec;
|
|
316
|
+
}
|
|
317
|
+
async function findPendingByReceiptHash(receiptHash, home) {
|
|
318
|
+
return readPending(newPendingId(receiptHash), home);
|
|
319
|
+
}
|
|
320
|
+
async function getStoredEnvelope(receiptHash, home) {
|
|
321
|
+
try {
|
|
322
|
+
const parsed = JSON.parse(await fs.readFile(envelopeFile(receiptHash, home), 'utf-8'));
|
|
323
|
+
return parsed?.proof ? parsed : null;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function storeEnvelope(receiptHash, envelope, home) {
|
|
330
|
+
const f = envelopeFile(receiptHash, home);
|
|
331
|
+
await fs.mkdir(path.dirname(f), { recursive: true, mode: 0o700 });
|
|
332
|
+
const tmp = `${f}.tmp-${process.pid}`;
|
|
333
|
+
await fs.writeFile(tmp, JSON.stringify(envelope, null, 2), { mode: 0o600 });
|
|
334
|
+
await fs.rename(tmp, f);
|
|
335
|
+
return f;
|
|
336
|
+
}
|
|
337
|
+
/** 收到付款后落一条待办 (幂等: 同一 receiptHash 已存在就原样返回, 不动盘) */
|
|
338
|
+
async function enqueuePending(input, home, now = Date.now()) {
|
|
339
|
+
const receiptHash = `sha256:${(0, paid_info_protocol_js_1.sha256Hex)(input.payment.receipt)}`;
|
|
340
|
+
const pendingId = newPendingId(receiptHash);
|
|
341
|
+
const existing = await readPending(pendingId, home);
|
|
342
|
+
if (existing) {
|
|
343
|
+
// 幂等: 已有待办 (哪怕已签) 一律原样返回, 不产生第二条
|
|
344
|
+
if (await getStoredEnvelope(receiptHash, home))
|
|
345
|
+
return { pending: existing, created: false };
|
|
346
|
+
return { pending: existing, created: false };
|
|
347
|
+
}
|
|
348
|
+
const rec = {
|
|
349
|
+
protocol: exports.SELLER_PROTOCOL,
|
|
350
|
+
pendingId,
|
|
351
|
+
status: 'awaiting_signature',
|
|
352
|
+
itemId: input.item.id,
|
|
353
|
+
title: input.item.title,
|
|
354
|
+
providerDid: input.item.provider?.did || '',
|
|
355
|
+
contentHash: input.item.contentHash,
|
|
356
|
+
...(input.item.contentCid ? { contentCid: input.item.contentCid } : {}),
|
|
357
|
+
source: input.item.source,
|
|
358
|
+
price: { ...input.item.price },
|
|
359
|
+
payment: {
|
|
360
|
+
mode: input.payment.mode,
|
|
361
|
+
receipt: input.payment.receipt,
|
|
362
|
+
receiptHash,
|
|
363
|
+
...(input.payment.txHash ? { txHash: input.payment.txHash } : {}),
|
|
364
|
+
...(input.payment.payer ? { payer: input.payment.payer } : {}),
|
|
365
|
+
network: input.payment.network,
|
|
366
|
+
amount: input.payment.amount,
|
|
367
|
+
currency: input.payment.currency,
|
|
368
|
+
settledAt: input.payment.settledAt || new Date(now).toISOString(),
|
|
369
|
+
},
|
|
370
|
+
createdAt: new Date(now).toISOString(),
|
|
371
|
+
expiresAt: new Date(now + exports.PENDING_TTL_MS).toISOString(),
|
|
372
|
+
};
|
|
373
|
+
await writePending(rec, home);
|
|
374
|
+
return { pending: rec, created: true };
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* ★ 买方的取件通道: 用 **取件 token** (202 响应里给的 pendingId) 再取一次。
|
|
378
|
+
*
|
|
379
|
+
* 为什么不靠 "重放同一张 X-PAYMENT":
|
|
380
|
+
* `checkAndSettlePayment` 每次结算都会**再跑一次** verify/settle, 而回执本身带
|
|
381
|
+
* 时间戳 (local-dev 的 `settledAt` 是 `new Date()`) ⇒ "重放凭据" 会得到**另一个**
|
|
382
|
+
* receiptHash ⇒ 又落一条新待办, 永远取不回刚才那个信封; facilitator 模式下重放还会
|
|
383
|
+
* 被 facilitator 判成重复结算 (钱已经动过, 不该再动)。所以取件必须有**独立的只读通道**。
|
|
384
|
+
*
|
|
385
|
+
* token 强度 (如实): pendingId = sha256(receiptHash) 前 16 位 hex = 64 bit, 由**买方自己的
|
|
386
|
+
* 凭据哈希**派生 ⇒ 只有付过款的一方拿得到。它是**不记名 token** (谁拿到谁能取内容):
|
|
387
|
+
* facilitator 模式下 receipt 由 facilitator 签发 (含 txHash), 买方之外不可推导;
|
|
388
|
+
* local-dev 模式下买方能自己算出 receipt ⇒ token 不提供任何保密性 (**联调模式不是安全边界**)。
|
|
389
|
+
*/
|
|
390
|
+
async function resolvePendingDelivery(args, home, now = Date.now()) {
|
|
391
|
+
const rec = await readPending(args.pendingId, home);
|
|
392
|
+
if (!rec)
|
|
393
|
+
return null;
|
|
394
|
+
// token 绑定本条 item: 拿 A 的 token 去读 B 的路径 → 当作不存在
|
|
395
|
+
if (rec.itemId !== args.itemId)
|
|
396
|
+
return null;
|
|
397
|
+
const view = pendingView(rec, now);
|
|
398
|
+
if (view.status === 'expired') {
|
|
399
|
+
return { kind: 'expired', pendingId: rec.pendingId, pending: view, hint: '待办已过期 (卖方长期未签) — 请人工处理, 不要重复付款' };
|
|
400
|
+
}
|
|
401
|
+
if (view.status === 'signed') {
|
|
402
|
+
const env = await getStoredEnvelope(rec.payment.receiptHash, home);
|
|
403
|
+
if (env)
|
|
404
|
+
return { kind: 'envelope', envelope: env, pending: view, pendingId: rec.pendingId, hint: '交付完成: 请用 ed25519Verify / verifyEnvelope 离线验签' };
|
|
405
|
+
// 状态写着已签但没有信封 = 事实不自洽 → 如实说, 不许拿旧内容冒充
|
|
406
|
+
return { kind: 'awaiting_signature', pending: view, pendingId: rec.pendingId, hint: '待办标记为已签但信封文件缺失 — 这不是"交付完成", 请人工核对' };
|
|
407
|
+
}
|
|
408
|
+
const ageSec = Math.max(0, Math.round((now - Date.parse(rec.createdAt)) / 1000));
|
|
409
|
+
return {
|
|
410
|
+
kind: 'awaiting_signature',
|
|
411
|
+
pending: view,
|
|
412
|
+
pendingId: rec.pendingId,
|
|
413
|
+
hint: `卖方还没签名 (已等 ${ageSec}s): 稍后用**同一个 token** 再取; 卖方不在线时就一直停在这里 —— 这是"已付款待签名", 不是失败`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/** 买方**刚付完款**时走这条: 已经签过就把信封给出去, 没签过就落待办并回 "已付款待签名" */
|
|
417
|
+
async function resolvePaidDelivery(args, home, now = Date.now()) {
|
|
418
|
+
const receiptHash = `sha256:${(0, paid_info_protocol_js_1.sha256Hex)(args.payment.receipt)}`;
|
|
419
|
+
const signed = await getStoredEnvelope(receiptHash, home);
|
|
420
|
+
const pendingId = newPendingId(receiptHash);
|
|
421
|
+
if (signed) {
|
|
422
|
+
const pending = await readPending(pendingId, home);
|
|
423
|
+
return { kind: 'envelope', envelope: signed, ...(pending ? { pending } : {}), pendingId, hint: '交付完成: 请用 ed25519Verify / verifyEnvelope 离线验签' };
|
|
424
|
+
}
|
|
425
|
+
const { pending } = await enqueuePending({ item: args.item, payment: args.payment }, home, now);
|
|
426
|
+
if (new Date(pending.expiresAt).getTime() < now && pending.status !== 'signed') {
|
|
427
|
+
return { kind: 'expired', pending, pendingId, hint: '待办已过期: 卖方长期未签, 请人工处理 (不要重复付款)' };
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
kind: 'awaiting_signature',
|
|
431
|
+
pending,
|
|
432
|
+
pendingId,
|
|
433
|
+
hint: '已付款待签名: 卖方本机尚未签发信封 (卖方不在线时拿不到内容, 这不是失败 —— 凭据已绑定本条资源, 签好后用**取件 token** GET /api/x402/info/<itemId>/pending/<pendingId> 取回; 别重放 X-PAYMENT, 那会再跑一遍结算)',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* 服务器侧: 收下本机签好的信封。**四道门**, 缺一道都不收:
|
|
438
|
+
* ① 待办存在且还是 awaiting_signature
|
|
439
|
+
* ② 签名自带公钥 == 钉住的卖方公钥 (防 "谁都能签一个信封塞进来")
|
|
440
|
+
* ③ ed25519Verify 通过 (载荷 = canonical(proof.payload))
|
|
441
|
+
* ④ 载荷自洽: itemId / providerDid / contentHash / receiptHash 与待办逐字一致
|
|
442
|
+
* + 内容本体哈希 == item.contentHash (防 "挂 A 卖 B" 在服务器侧被悄悄放过)
|
|
443
|
+
*/
|
|
444
|
+
async function acceptSignedEnvelope(args, home) {
|
|
445
|
+
const pending = await readPending(args.pendingId, home);
|
|
446
|
+
if (!pending)
|
|
447
|
+
return { ok: false, code: 'PENDING_NOT_FOUND', detail: `没有这条待办: ${args.pendingId}` };
|
|
448
|
+
const env = args.envelope;
|
|
449
|
+
if (!env?.proof?.signature || !env?.proof?.payload || !env?.proof?.publicKeyHex) {
|
|
450
|
+
return { ok: false, code: 'ENVELOPE_MISSING_PROOF', detail: '信封缺少 proof.signature / payload / publicKeyHex' };
|
|
451
|
+
}
|
|
452
|
+
if (pending.status === 'signed') {
|
|
453
|
+
const existing = await getStoredEnvelope(pending.payment.receiptHash, home);
|
|
454
|
+
const same = !!existing && (0, paid_info_protocol_js_1.canonicalize)(existing) === (0, paid_info_protocol_js_1.canonicalize)(env);
|
|
455
|
+
if (same)
|
|
456
|
+
return { ok: true, detail: '这条待办已经收过同一个信封 (幂等)', envelopeHash: `sha256:${(0, paid_info_protocol_js_1.sha256Hex)((0, paid_info_protocol_js_1.canonicalize)(env))}`, envelope: existing };
|
|
457
|
+
return { ok: false, code: 'PENDING_ALREADY_SIGNED', detail: '这条待办已经签过, 且这次提交的是**另一个**信封 —— 拒绝改写已交付的事实' };
|
|
458
|
+
}
|
|
459
|
+
if (pending.status === 'expired' || Date.parse(pending.expiresAt) < Date.now()) {
|
|
460
|
+
return { ok: false, code: 'PENDING_EXPIRED', detail: `待办已过期 (${pending.expiresAt}) —— 拒绝签发` };
|
|
461
|
+
}
|
|
462
|
+
if (args.sellerPublicKeyHex) {
|
|
463
|
+
if (String(env.proof.publicKeyHex).toLowerCase() !== String(args.sellerPublicKeyHex).toLowerCase()) {
|
|
464
|
+
return { ok: false, code: 'SELLER_KEY_MISMATCH', detail: '签名公钥与本部署钉住的卖方公钥不一致 —— 拒绝收下' };
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
return { ok: false, code: 'SELLER_KEY_NOT_PINNED', detail: '本部署未钉住卖方公钥 → 无法判断这个信封是不是卖方签的 (拒绝收下, 不是"先信一次")' };
|
|
469
|
+
}
|
|
470
|
+
const sigOk = await (0, paid_info_protocol_js_1.ed25519Verify)(env.proof.publicKeyHex, (0, paid_info_protocol_js_1.canonicalize)(env.proof.payload), env.proof.signature);
|
|
471
|
+
if (!sigOk)
|
|
472
|
+
return { ok: false, code: 'SIGNATURE_INVALID', detail: 'ed25519Verify 失败 (载荷被改动, 或不是这把钥匙签的)' };
|
|
473
|
+
const p = env.proof.payload;
|
|
474
|
+
if (p.itemId !== pending.itemId || p.providerDid !== pending.providerDid || p.contentHash !== pending.contentHash || p.receiptHash !== pending.payment.receiptHash) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
code: 'PAYLOAD_MISMATCH',
|
|
478
|
+
detail: `载荷绑定与待办不一致 (itemId ${p.itemId === pending.itemId ? 'ok' : 'MISMATCH'} · providerDid ${p.providerDid === pending.providerDid ? 'ok' : 'MISMATCH'} · contentHash ${p.contentHash === pending.contentHash ? 'ok' : 'MISMATCH'} · receiptHash ${p.receiptHash === pending.payment.receiptHash ? 'ok' : 'MISMATCH'})`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const actualHash = (0, paid_info_protocol_js_1.computeContentHash)(String(env.content ?? ''));
|
|
482
|
+
if (actualHash !== pending.contentHash) {
|
|
483
|
+
return { ok: false, code: 'CONTENT_HASH_MISMATCH', detail: `内容哈希对不上 (收到 ${actualHash}, 待办要求 ${pending.contentHash}) —— 拒绝收下` };
|
|
484
|
+
}
|
|
485
|
+
if (pending.status !== 'awaiting_signature' && pending.status !== 'signed') {
|
|
486
|
+
return { ok: false, code: 'PENDING_STATE_INVALID', detail: `待办状态 ${pending.status} 不能接收信封` };
|
|
487
|
+
}
|
|
488
|
+
const envelopeHash = `sha256:${(0, paid_info_protocol_js_1.sha256Hex)((0, paid_info_protocol_js_1.canonicalize)(env))}`;
|
|
489
|
+
await storeEnvelope(pending.payment.receiptHash, env, home);
|
|
490
|
+
await writePending({ ...pending, status: 'signed', signedAt: new Date().toISOString(), envelopeHash }, home);
|
|
491
|
+
return { ok: true, detail: '信封已验签收下 (服务器只验证, 不持有私钥)', envelopeHash, envelope: env };
|
|
492
|
+
}
|
|
493
|
+
function sellerApiPaths() {
|
|
494
|
+
return {
|
|
495
|
+
list: `${exports.SELLER_API_PREFIX}/pending`,
|
|
496
|
+
show: (id) => `${exports.SELLER_API_PREFIX}/pending/${encodeURIComponent(id)}`,
|
|
497
|
+
envelope: (id) => `${exports.SELLER_API_PREFIX}/pending/${encodeURIComponent(id)}/envelope`,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/** 买方取件路径 (只读, 不用付款头, 不重跑结算; token = 202 响应里的 pendingId) */
|
|
501
|
+
function paidInfoRetrievePath(itemId, pendingId) {
|
|
502
|
+
return `/api/x402/info/${encodeURIComponent(itemId)}/pending/${encodeURIComponent(pendingId)}`;
|
|
503
|
+
}
|
|
504
|
+
function json(res, status, obj) {
|
|
505
|
+
return { ...res, status, body: JSON.stringify(obj, null, 2), headers: { ...res.headers, 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } };
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* 处理 `/api/x402/seller/**`。返回 {status, headers, body}; **不碰** 402 那条路。
|
|
509
|
+
* 服务端 (server.mjs) 只需把 req 转成 SellerHttpRequest 再写回。
|
|
510
|
+
*/
|
|
511
|
+
async function handleSellerApi(opts, req) {
|
|
512
|
+
const home = opts.home;
|
|
513
|
+
const now = opts.now ? opts.now() : Date.now();
|
|
514
|
+
const base = { status: 500, headers: {}, body: '' };
|
|
515
|
+
const auth = opts.auth !== undefined ? opts.auth : await loadSellerAuth(home);
|
|
516
|
+
const ledger = opts.ledger ?? new NonceLedger(path.join(sellerPendingDir(home), '.nonces.json'));
|
|
517
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
518
|
+
// 查询串不参与签名 (signSellerRequest 只签 pathname) —— 这里也只用 pathname
|
|
519
|
+
const p = String(req.path || '').replace(/\/+$/, '') || '/';
|
|
520
|
+
if (method !== 'GET' && method !== 'POST') {
|
|
521
|
+
return json({ ...base, headers: { Allow: 'GET, POST' } }, 405, { error: '队列端点只认 GET / POST' });
|
|
522
|
+
}
|
|
523
|
+
// ① 认证 (所有队列端点都要)
|
|
524
|
+
const verdict = await verifySellerAuth(auth, { method, path: req.path, headers: req.headers, body: req.bodyText }, ledger, now);
|
|
525
|
+
if (!verdict.ok) {
|
|
526
|
+
const status = verdict.code === 'SELLER_AUTH_NOT_CONFIGURED' ? 403 : 401;
|
|
527
|
+
return json(base, status, { ok: false, code: verdict.code, error: verdict.detail, hint: verdict.code === 'SELLER_AUTH_NOT_CONFIGURED' ? '本部署未配置共享密钥 (0600 文件) → 卖方本机也无权拉取' : '检查本机 ~/.bolloon/x402-seller-auth.json 与服务端是否同一把密钥; 时间戳是否同步' });
|
|
528
|
+
}
|
|
529
|
+
const paths = sellerApiPaths();
|
|
530
|
+
const showMatch = p.match(new RegExp(`^${exports.SELLER_API_PREFIX}/pending/([^/]+)$`));
|
|
531
|
+
const envMatch = p.match(new RegExp(`^${exports.SELLER_API_PREFIX}/pending/([^/]+)/envelope$`));
|
|
532
|
+
// 列出待办
|
|
533
|
+
if (method === 'GET' && p === paths.list) {
|
|
534
|
+
const want = String(req.query?.status || '').trim();
|
|
535
|
+
const all = (await listPending(home)).map((r) => pendingView(r, now));
|
|
536
|
+
const filtered = want ? all.filter((r) => r.status === want) : all;
|
|
537
|
+
const limit = Math.min(Number(opts.maxList ?? 200) || 200, 1000);
|
|
538
|
+
const items = filtered.slice(0, limit);
|
|
539
|
+
return json(base, 200, {
|
|
540
|
+
ok: true,
|
|
541
|
+
protocol: exports.SELLER_PROTOCOL,
|
|
542
|
+
count: items.length,
|
|
543
|
+
total: all.length,
|
|
544
|
+
awaitingSignature: all.filter((r) => r.status === 'awaiting_signature').length,
|
|
545
|
+
// 待办里**不带** privateKey/secret; receipt 是结算凭据原文 (卖方要用它重建 receiptHash)
|
|
546
|
+
pending: items,
|
|
547
|
+
note: '私钥不在这里 —— 服务器只持卖方公钥; 本机签完 POST 回 /pending/:id/envelope 即可交付',
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
// 单条
|
|
551
|
+
if (method === 'GET' && showMatch) {
|
|
552
|
+
const rec = await readPending(decodeURIComponent(showMatch[1]), home);
|
|
553
|
+
if (!rec)
|
|
554
|
+
return json(base, 404, { ok: false, code: 'PENDING_NOT_FOUND', error: '没有这条待办' });
|
|
555
|
+
const view = pendingView(rec, now);
|
|
556
|
+
const envelope = view.status === 'signed' ? await getStoredEnvelope(view.payment.receiptHash, home) : null;
|
|
557
|
+
return json(base, 200, { ok: true, protocol: exports.SELLER_PROTOCOL, pending: view, envelopeHash: view.envelopeHash || null, delivered: !!envelope });
|
|
558
|
+
}
|
|
559
|
+
// 回传已签信封
|
|
560
|
+
if (method === 'POST' && envMatch) {
|
|
561
|
+
const pendingId = decodeURIComponent(envMatch[1]);
|
|
562
|
+
let body;
|
|
563
|
+
try {
|
|
564
|
+
body = JSON.parse(String(req.bodyText || ''));
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return json(base, 400, { ok: false, code: 'INVALID_ARGUMENT', error: '请求体不是合法 JSON' });
|
|
568
|
+
}
|
|
569
|
+
const envelope = body?.envelope ?? body;
|
|
570
|
+
const res = await acceptSignedEnvelope({
|
|
571
|
+
pendingId,
|
|
572
|
+
envelope,
|
|
573
|
+
sellerPublicKeyHex: opts.sellerPublicKeyHex,
|
|
574
|
+
sellerDid: opts.sellerDid,
|
|
575
|
+
}, home);
|
|
576
|
+
if (!res.ok) {
|
|
577
|
+
const status = res.code === 'PENDING_NOT_FOUND' ? 404 : res.code === 'PENDING_ALREADY_SIGNED' ? 409 : res.code === 'SELLER_KEY_NOT_PINNED' ? 503 : 400;
|
|
578
|
+
return json(base, status, { ok: false, code: res.code, error: res.detail });
|
|
579
|
+
}
|
|
580
|
+
return json(base, 200, {
|
|
581
|
+
ok: true,
|
|
582
|
+
pendingId,
|
|
583
|
+
status: 'signed',
|
|
584
|
+
envelopeHash: res.envelopeHash,
|
|
585
|
+
detail: res.detail,
|
|
586
|
+
buyerHint: '买方带**同一张** X-PAYMENT 再请求一次原 URL 即可取回信封',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return json(base, 404, { ok: false, code: 'NOT_FOUND', error: `未知队列路径: ${method} ${p}` });
|
|
590
|
+
}
|
|
591
|
+
/** 本机侧: 带认证地请求服务器队列端点 (签名在 signSellerRequest 里做)
|
|
592
|
+
* 注意: 被签的只有 **pathname**; `urlPath` 允许带查询串 (查询串不参与签名) */
|
|
593
|
+
async function sellerClientRequest(opts, req) {
|
|
594
|
+
const f = opts.fetchImpl ?? fetch;
|
|
595
|
+
const url = `${opts.endpoint.replace(/\/+$/, '')}${req.urlPath ?? req.path}`;
|
|
596
|
+
const headers = { ...signSellerRequest(opts.auth, { method: req.method, path: req.path, body: req.body }) };
|
|
597
|
+
if (req.body !== undefined)
|
|
598
|
+
headers['Content-Type'] = 'application/json';
|
|
599
|
+
const res = await f(url, { method: req.method, headers, ...(req.body !== undefined ? { body: req.body } : {}) });
|
|
600
|
+
const text = await res.text();
|
|
601
|
+
let parsed = null;
|
|
602
|
+
try {
|
|
603
|
+
parsed = JSON.parse(text);
|
|
604
|
+
}
|
|
605
|
+
catch { /* 非 JSON 也如实带回原文 */ }
|
|
606
|
+
return { status: res.status, ok: res.ok, json: parsed, text };
|
|
607
|
+
}
|
|
608
|
+
/** 本机侧: 从待办 + 本机 item/内容 构造已签信封 (**复用既有 buildSignedEnvelope → ed25519Sign**) */
|
|
609
|
+
async function signPending(args) {
|
|
610
|
+
const { pending, item, content, keypair } = args;
|
|
611
|
+
if (String(item.contentHash) !== String(pending.contentHash)) {
|
|
612
|
+
throw new Error(`本机 item 的内容哈希 (${String(item.contentHash).slice(0, 28)}…) 与待办要求 (${String(pending.contentHash).slice(0, 28)}…) 不一致 —— 拒签 (可能本机内容已改版)`);
|
|
613
|
+
}
|
|
614
|
+
if (keypair.did && pending.providerDid && keypair.did !== pending.providerDid) {
|
|
615
|
+
throw new Error(`本机钥匙的 DID (${keypair.did}) 不是这条 item 的卖方 (${pending.providerDid}) —— 拒签`);
|
|
616
|
+
}
|
|
617
|
+
return (0, paid_info_protocol_js_1.buildSignedEnvelope)({
|
|
618
|
+
item: item,
|
|
619
|
+
content,
|
|
620
|
+
keypair: keypair,
|
|
621
|
+
payment: {
|
|
622
|
+
mode: pending.payment.mode,
|
|
623
|
+
receipt: pending.payment.receipt,
|
|
624
|
+
...(pending.payment.txHash ? { txHash: pending.payment.txHash } : {}),
|
|
625
|
+
network: pending.payment.network,
|
|
626
|
+
amount: pending.payment.amount,
|
|
627
|
+
currency: pending.payment.currency,
|
|
628
|
+
...(pending.payment.payer ? { payer: pending.payment.payer } : {}),
|
|
629
|
+
settledAt: pending.payment.settledAt,
|
|
630
|
+
},
|
|
631
|
+
issuedAt: new Date().toISOString(),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* 找到能给这个 DID 签名的本机钥匙。**只读既有身份文件, 绝不新建**:
|
|
636
|
+
* ① --agent <agentId> → ~/.bolloon/agent-keys/<agentId>.json
|
|
637
|
+
* ② ~/.bolloon/identity.json (DIAP 身份, 与 `routes-x402-info.ts` 的 loadProviderKeypair 同源)
|
|
638
|
+
* ③ 扫 ~/.bolloon/agent-keys/*.json 兜底
|
|
639
|
+
* 传了 did 就**必须**匹配 (否则等于用错钥匙签别人的单); 找不到就如实报错 ——
|
|
640
|
+
* 不静默生成一把新钥匙 (那等于换了个卖方身份)。
|
|
641
|
+
*/
|
|
642
|
+
async function resolveSellerKey(did, opts = {}) {
|
|
643
|
+
const home = sellerHome(opts.home);
|
|
644
|
+
const read = async (file, source) => {
|
|
645
|
+
try {
|
|
646
|
+
const j = JSON.parse(await fs.readFile(file, 'utf-8'));
|
|
647
|
+
if (!j?.privateKey)
|
|
648
|
+
return null;
|
|
649
|
+
if (did && j.did && j.did !== did)
|
|
650
|
+
return null;
|
|
651
|
+
const priv = String(j.privateKey).replace(/^0x/, '');
|
|
652
|
+
if (priv.length !== 64)
|
|
653
|
+
return null;
|
|
654
|
+
return { did: j.did || did, publicKeyHex: String(j.publicKey || '').replace(/^0x/, ''), privateKeyHex: priv, source };
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
if (opts.agentId) {
|
|
661
|
+
return read(path.join(home, '.bolloon', 'agent-keys', `${safeId(opts.agentId)}.json`), `agent-keys/${safeId(opts.agentId)}.json`);
|
|
662
|
+
}
|
|
663
|
+
// ★ 不指定 agent 时: **先认身份文件** (~/.bolloon/identity.json) —— 它与 `routes-x402-info.ts` 的
|
|
664
|
+
// loadProviderKeypair 同源, 也就是"这份内容是谁发布的"。不能按文件名排序抓 agent-keys 里的第一个:
|
|
665
|
+
// 本机 agent-keys/ 下通常躺着好几个测试 agent, 按排序第一个拿到的是**别人**的钥匙 ⇒
|
|
666
|
+
// 与待办 providerDid 对不上 (甚至可能"用错钥匙签了别人的单")。真撞过: 排序第一是 `agent-__.json`。
|
|
667
|
+
const id = await read(path.join(home, '.bolloon', 'identity.json'), 'identity.json');
|
|
668
|
+
if (id)
|
|
669
|
+
return id;
|
|
670
|
+
const keysDir = path.join(home, '.bolloon', 'agent-keys');
|
|
671
|
+
try {
|
|
672
|
+
const files = (await fs.readdir(keysDir)).filter((f) => f.endsWith('.json')).sort();
|
|
673
|
+
for (const f of files) {
|
|
674
|
+
const hit = await read(path.join(keysDir, f), `agent-keys/${f}`);
|
|
675
|
+
if (hit)
|
|
676
|
+
return hit;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
catch { /* 没有 agent-keys 目录 */ }
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
//# sourceMappingURL=seller-signing.js.map
|