trustbase-seller-backend 0.7.5 → 0.7.7
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/core/chain-reconcile.js +14 -3
- package/middleware/api-key-auth.js +5 -1
- package/package.json +1 -1
- package/plugins/sqlite.js +14 -1
- package/routes/buyer-shop.js +6 -2
- package/routes/chain-writes.js +31 -25
- package/run-unified.js +6 -6
- package/test-api-key-auth-exempt.js +35 -5
- package/test-chain-reconcile.js +23 -0
- package/test-chain-writes-audit-integration.js +27 -1
- package/test-product-type.js +14 -10
- package/test-sqlite-product-type-preserve.js +64 -0
- package/web/dist/app.js +16 -2
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +1 -1
package/core/chain-reconcile.js
CHANGED
|
@@ -49,12 +49,21 @@ async function fetchSlots(lcdUrl, seller) {
|
|
|
49
49
|
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
|
50
50
|
if (!res.ok) throw new Error(`LCD ${res.status} ${url}`);
|
|
51
51
|
const body = await res.json();
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// 2026-10-18 fix: 200 + 错误形状不能再被当成“零 slot 成功”。
|
|
53
|
+
// 只接受 {slots:{slots:[...]}},其余一律视为上游异常并进入 degraded。
|
|
54
|
+
if (!body || !body.slots || !Array.isArray(body.slots.slots)) {
|
|
55
|
+
throw new Error(`LCD slots 响应形状错误 ${url}`);
|
|
56
|
+
}
|
|
57
|
+
return body.slots.slots;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
function updateLocalFromSlot(db, product, slot) {
|
|
57
61
|
const createdMs = Number(slot.created_at) > 0 ? Number(slot.created_at) * 1000 : Date.now();
|
|
62
|
+
// 2026-10-18 fix: 本地已有 chain_stake_per_unit 时保留卖家原始快照,
|
|
63
|
+
// 不能用链上声誉调整后的值覆盖。只有本地为空时才用链上值补齐。
|
|
64
|
+
const stake = (product.chain_stake_per_unit === null || product.chain_stake_per_unit === undefined || product.chain_stake_per_unit === "")
|
|
65
|
+
? String(slot.stake_per_unit || "")
|
|
66
|
+
: product.chain_stake_per_unit;
|
|
58
67
|
db.prepare(`UPDATE store_products SET
|
|
59
68
|
chain_listed = 1,
|
|
60
69
|
chain_sku_id = ?,
|
|
@@ -62,7 +71,7 @@ function updateLocalFromSlot(db, product, slot) {
|
|
|
62
71
|
chain_stake_per_unit = ?,
|
|
63
72
|
chain_listed_at = ?
|
|
64
73
|
WHERE id = ?`)
|
|
65
|
-
.run(slot.sku_id, Number(slot.slot_index),
|
|
74
|
+
.run(slot.sku_id, Number(slot.slot_index), stake, createdMs, product.id);
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
/**
|
|
@@ -132,6 +141,8 @@ function startChainReconcile({ db, lcdUrl }) {
|
|
|
132
141
|
console.log(`[ChainReconcile] ${r.status}: recovered=${r.recovered} warnings=${r.warnings} errors=${r.errors} checked=${r.checked}`);
|
|
133
142
|
} catch (e) {
|
|
134
143
|
console.error("[ChainReconcile] failed:", e.message);
|
|
144
|
+
// 2026-10-18: 启动/定时失败也必须留下 degraded 状态,不能沿用旧状态或为空。
|
|
145
|
+
try { saveState(db, "degraded", e.message); } catch (_) { /* 审计不阻塞业务 */ }
|
|
135
146
|
} finally {
|
|
136
147
|
running = false;
|
|
137
148
|
}
|
|
@@ -160,10 +160,14 @@ function listAPIKeys() {
|
|
|
160
160
|
*/
|
|
161
161
|
/** 隧道买家面放行判定 (2026-10-03, T2/Lane B)
|
|
162
162
|
* 三条同时成立才放行: ① 来源回环 ② X-TB-Tunnel: 1 头 ③ 买家只读路径 */
|
|
163
|
-
const TUNNEL_BUYER_PATHS = ['/api/products', '/api/orders'];
|
|
163
|
+
const TUNNEL_BUYER_PATHS = ['/api/products', '/api/orders', '/api/qr'];
|
|
164
164
|
function isTunnelBuyerRequest(req, clientIP) {
|
|
165
165
|
const fromLoopback = clientIP === '127.0.0.1' || clientIP === '::1' || /^127\./.test(clientIP);
|
|
166
166
|
if (!fromLoopback) return false;
|
|
167
|
+
// 2026-10-18 security: 隧道放行只允许只读请求。
|
|
168
|
+
// 原实现未检查方法,带 X-TB-Tunnel 的 POST /api/products 等写操作会连续绕过
|
|
169
|
+
// requireAPIKey 与 sensitiveOpGuard 两层鉴权,形成真实写操作绕过。
|
|
170
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
|
167
171
|
if (String(req.headers['x-tb-tunnel'] || '') !== '1') return false;
|
|
168
172
|
// 注意: 本中间件挂在 '/api' 上, req.path 是挂载后路径 ('/products'), req.originalUrl 才带 '/api'
|
|
169
173
|
// → 两种前缀都要判, 否则放行条件恒为假 (2026-10-03 .211 实测 401 才发现)
|
package/package.json
CHANGED
package/plugins/sqlite.js
CHANGED
|
@@ -871,11 +871,24 @@ class SQLitePlugin {
|
|
|
871
871
|
}
|
|
872
872
|
if (this.storeProductsReady) {
|
|
873
873
|
const imgArr = Array.isArray(product.images) ? product.images : [];
|
|
874
|
+
// 2026-10-18 fix: 不再 INSERT OR REPLACE。
|
|
875
|
+
// REPLACE 会删旧行重插,未提供的 product_type 会被重置成默认 physical。
|
|
876
|
+
// 改为 ON CONFLICT 只更新链同步负责的列,明确保留本地 product_type。
|
|
874
877
|
this.db.prepare(`
|
|
875
|
-
INSERT
|
|
878
|
+
INSERT INTO store_products (
|
|
876
879
|
id, seller_address, title, description, price_utct, stock, status,
|
|
877
880
|
category, image_url, tags, featured, sales_count, created_at, updated_at
|
|
878
881
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', 0, 0, ?, ?)
|
|
882
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
883
|
+
seller_address = excluded.seller_address,
|
|
884
|
+
title = excluded.title,
|
|
885
|
+
description = excluded.description,
|
|
886
|
+
price_utct = excluded.price_utct,
|
|
887
|
+
stock = excluded.stock,
|
|
888
|
+
status = excluded.status,
|
|
889
|
+
category = excluded.category,
|
|
890
|
+
image_url = excluded.image_url,
|
|
891
|
+
updated_at = excluded.updated_at
|
|
879
892
|
`).run(product.skuId, product.seller, product.title, product.description, Math.round(Number(product.priceTct || 0) * 1_000_000), // price_tct(TCT 字符串) → utct
|
|
880
893
|
product.stock, product.status === 'active' ? 'active' : 'archived', product.category.level1 || 'general', imgArr[0] || null, product.createdAt, product.updatedAt);
|
|
881
894
|
}
|
package/routes/buyer-shop.js
CHANGED
|
@@ -282,10 +282,14 @@ function createBuyerShopRouter(deps) {
|
|
|
282
282
|
const c = db.prepare("SELECT txhash FROM chain_write_log WHERE order_id = ?").get(row.chain_order_id);
|
|
283
283
|
if (c && c.txhash) txHash = String(c.txhash);
|
|
284
284
|
}
|
|
285
|
+
const status = mapStatus(row.state);
|
|
286
|
+
// 2026-10-18 security: 交付凭据只能在支付成功后公开。
|
|
287
|
+
// 原实现创建订单 pending 时就返回 delivery_credential,买家可不付款直接取走。
|
|
288
|
+
const canDeliver = status === "paid" || status === "completed";
|
|
285
289
|
return {
|
|
286
290
|
id: row.order_ref,
|
|
287
291
|
product_id: row.sku_id, // Pi 复核 P2: 前端详情页跳转要 product_id (契约补齐)
|
|
288
|
-
status
|
|
292
|
+
status,
|
|
289
293
|
amount: fenToYuan(row.amount_total),
|
|
290
294
|
product_name: productName,
|
|
291
295
|
quantity: row.qty,
|
|
@@ -293,7 +297,7 @@ function createBuyerShopRouter(deps) {
|
|
|
293
297
|
code_url: row.psp_code_url || "",
|
|
294
298
|
// A1: 收货地址 / 交付凭据 (老订单两列为空 → 空串)
|
|
295
299
|
shipping_address: String(row.shipping_address || ""),
|
|
296
|
-
delivery_credential: String(row.delivery_credential || ""),
|
|
300
|
+
delivery_credential: canDeliver ? String(row.delivery_credential || "") : "",
|
|
297
301
|
};
|
|
298
302
|
}
|
|
299
303
|
|
package/routes/chain-writes.js
CHANGED
|
@@ -208,7 +208,7 @@ async function queryAccount(c, addr) {
|
|
|
208
208
|
if (!acct || acct.account_number === undefined || acct.sequence === undefined) {
|
|
209
209
|
throw new HttpError(502, 'lcd_upstream_error', `LCD 账户响应缺 account_number/sequence: ${addr}`);
|
|
210
210
|
}
|
|
211
|
-
return { accountNumber: String(acct.account_number), sequence: String(acct.sequence) };
|
|
211
|
+
return { accountNumber: String(acct.account_number), sequence: String(acct.sequence), address: addr };
|
|
212
212
|
}
|
|
213
213
|
/** 手写 unsigned tx JSON (cosmos proto JSON 格式, snake_case 原名 + uint64 字符串)
|
|
214
214
|
* memo: v3.9.4 起下单走 "tb-auth:<sha256(整批订单意愿)>" (见 core/order-intent.js);
|
|
@@ -415,30 +415,36 @@ function tryParseBroadcast(out) {
|
|
|
415
415
|
*/
|
|
416
416
|
exports.sendTxAs = sendTxAs;
|
|
417
417
|
async function sendTxAs(c, messages, addr, opts) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
418
|
+
// 2026-10-18 fix: sendTxAs 也必须经过上链审计。
|
|
419
|
+
// 原实现直接调 signAndBroadcastInner,订单/支付/入驻等主要写路径全部绕过审计。
|
|
420
|
+
const run = () => chain_write_log_1.withChainWriteLog({
|
|
421
|
+
messages,
|
|
422
|
+
account: { address: addr },
|
|
423
|
+
run: async () => {
|
|
424
|
+
let account;
|
|
425
|
+
try {
|
|
426
|
+
account = await queryAccount(c, addr);
|
|
427
|
+
}
|
|
428
|
+
catch (e) {
|
|
429
|
+
// N15: 0 余额新商户 (auth 账户都不存在) + feegrant 代付 → 首笔 tx 以账户号 0/0 发起
|
|
430
|
+
const isNotFound = (e && e.code === 'account_not_found') || /account_not_found/.test(String(e && e.message));
|
|
431
|
+
if (!isNotFound || !c.feeGranter)
|
|
432
|
+
throw e;
|
|
433
|
+
console.log(`[chain-writes] 地址未上链 + feegrant 代付, 以 account 0/0 发起首笔 tx: ${addr}`);
|
|
434
|
+
account = { accountNumber: '0', sequence: '0' };
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
return await signAndBroadcastInner(c, messages, account, opts);
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
if (!(e instanceof SequenceMismatchError))
|
|
441
|
+
throw e;
|
|
442
|
+
console.warn('[chain-writes] account sequence mismatch, 重查序号重试一次');
|
|
443
|
+
account = await queryAccount(c, addr);
|
|
444
|
+
return await signAndBroadcastInner(c, messages, account, opts);
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
});
|
|
442
448
|
const p = sendQueue.then(run, run);
|
|
443
449
|
sendQueue = p.then(() => undefined, () => undefined);
|
|
444
450
|
return p;
|
package/run-unified.js
CHANGED
|
@@ -296,11 +296,6 @@ async function startUnifiedServer() {
|
|
|
296
296
|
await indexer.start();
|
|
297
297
|
// 2026-10-17 kimi: 初始化上链尝试审计(append-only,独立于幂等表 chain_write_log)。
|
|
298
298
|
chain_write_log_1.initChainWriteLogger(sqliteIndexer.getRawDb());
|
|
299
|
-
// 2026-10-17 kimi: 启动时 + 定时对账;链上读失败显式 degraded,不静默返回缓存。
|
|
300
|
-
chain_reconcile_1.startChainReconcile({
|
|
301
|
-
db: sqliteIndexer.getRawDb(),
|
|
302
|
-
lcdUrl: process.env.CHAIN_LCD_URL || process.env.LCD_URL || 'http://localhost:1317',
|
|
303
|
-
});
|
|
304
299
|
// ── F-01 fix: ChainListener 接回 run-unified ──
|
|
305
300
|
// SQLitePlugin.start() 本身不 wire ChainListener (只有 TrustCoinIndexer.start() wire).
|
|
306
301
|
// 原来 run.ts 走 TrustCoinIndexer 所以 listener 自动起来, run-unified 直用
|
|
@@ -443,7 +438,7 @@ async function startUnifiedServer() {
|
|
|
443
438
|
// (pwa-shop-demo 契约) 无账号无 api key。写路径仍受下方 sensitiveOpGuard 保护:
|
|
444
439
|
// POST /api/products (卖家上架) 强制 key 不变; POST /api/orders 由 buyer-shop 按 body 形状
|
|
445
440
|
// 接管买家单 (buyer_card+product_id), 其余形状落回 guard + chain-writes 老路径。
|
|
446
|
-
app.use('/api', (0, api_key_auth_1.requireAPIKey)(skipLAN, ['/api/p2p/mcp', '/api/p2p/oauth', '/api/products', '/api/orders']));
|
|
441
|
+
app.use('/api', (0, api_key_auth_1.requireAPIKey)(skipLAN, ['/api/p2p/mcp', '/api/p2p/oauth', '/api/products', '/api/orders', '/api/qr']));
|
|
447
442
|
// v0.17.1 安全修复 (H2): 以下三族路由此前挂载在 requireAPIKey 之前,
|
|
448
443
|
// 任意外网 IP 都能无认证调用 —— 特别是 POST /api/api-keys/create
|
|
449
444
|
// 允许任何人给自己签发任意权限的 key。现已挪到鉴权中间件之后。
|
|
@@ -483,6 +478,11 @@ async function startUnifiedServer() {
|
|
|
483
478
|
if (sqliteDb) {
|
|
484
479
|
(0, social_migrate_1.runSocialMigrations)(sqliteDb); // kimi 8/26: 私田 4 表 (幂等), 不修会 no such table (实测)
|
|
485
480
|
(0, store_migrate_1.runStoreMigrations)(sqliteIndexer.getRawDb()); // kimi 9/6: storefront store_products 等表 (幂等), 不修 storefront 查询炸 no such table
|
|
481
|
+
// 2026-10-18 fix: 首次对账必须在 store_products 建表之后,避免新库 no such table。
|
|
482
|
+
chain_reconcile_1.startChainReconcile({
|
|
483
|
+
db: sqliteIndexer.getRawDb(),
|
|
484
|
+
lcdUrl: process.env.CHAIN_LCD_URL || process.env.LCD_URL || 'http://localhost:1317',
|
|
485
|
+
});
|
|
486
486
|
app.use('/api/social', (0, social_router_1.createSocialFieldRouter)({ db: sqliteDb }));
|
|
487
487
|
console.log(' ✅ SocialField router mounted at /api/social/{posts,shares,whitelist,feed}');
|
|
488
488
|
}
|
|
@@ -54,7 +54,7 @@ CREATE TABLE store_products (
|
|
|
54
54
|
|
|
55
55
|
const app = express();
|
|
56
56
|
app.use(express.json());
|
|
57
|
-
app.use("/api", requireAPIKey(false, ["/api/p2p/mcp", "/api/p2p/oauth", "/api/products", "/api/orders"]));
|
|
57
|
+
app.use("/api", requireAPIKey(false, ["/api/p2p/mcp", "/api/p2p/oauth", "/api/products", "/api/orders", "/api/qr"]));
|
|
58
58
|
app.use("/api", buyerShop.createBuyerShopRouter({ db }));
|
|
59
59
|
app.use("/api/orders", sensitiveOpGuard);
|
|
60
60
|
app.use("/api/products", sensitiveOpGuard);
|
|
@@ -74,6 +74,7 @@ async function closeServer(ctx) {
|
|
|
74
74
|
|
|
75
75
|
const cases = [
|
|
76
76
|
{ name: "GET /api/products 公开 200", method: "GET", path: "/api/products", status: 200 },
|
|
77
|
+
{ name: "GET /api/qr 支付二维码不再 401", method: "GET", path: "/api/qr?text=weixin%3A%2F%2Fwxpay%2Fexample", status: 200 },
|
|
77
78
|
{ name: "GET /api/products?limit=1 query 不影响豁免", method: "GET", path: "/api/products?limit=1", status: 200 },
|
|
78
79
|
{ name: "GET /api/products/p1?expand=1 子路径与 query 公开", method: "GET", path: "/api/products/p1?expand=1", status: 200 },
|
|
79
80
|
{ name: "GET /api/status 仍 401", method: "GET", path: "/api/status", status: 401 },
|
|
@@ -89,7 +90,30 @@ const cases = [
|
|
|
89
90
|
status: 401,
|
|
90
91
|
},
|
|
91
92
|
{ name: "POST /api/products 豁免后仍受 sensitiveOpGuard 拦截", method: "POST", path: "/api/products", body: {}, status: 401 },
|
|
93
|
+
{
|
|
94
|
+
name: "POST /api/products 带隧道头也不能绕过写操作鉴权",
|
|
95
|
+
method: "POST",
|
|
96
|
+
path: "/api/products",
|
|
97
|
+
headers: { "X-TB-Tunnel": "1" },
|
|
98
|
+
body: {},
|
|
99
|
+
status: 401,
|
|
100
|
+
},
|
|
92
101
|
{ name: "POST /api/orders 非买家形状仍受 sensitiveOpGuard 拦截", method: "POST", path: "/api/orders", body: { woo_order_id: 1 }, status: 401 },
|
|
102
|
+
{
|
|
103
|
+
name: "POST /api/orders 非买家形状带隧道头也不能绕过写操作鉴权",
|
|
104
|
+
method: "POST",
|
|
105
|
+
path: "/api/orders",
|
|
106
|
+
headers: { "X-TB-Tunnel": "1" },
|
|
107
|
+
body: { woo_order_id: 1 },
|
|
108
|
+
status: 401,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "GET /api/products 带隧道头仍只读放行",
|
|
112
|
+
method: "GET",
|
|
113
|
+
path: "/api/products",
|
|
114
|
+
headers: { "X-TB-Tunnel": "1" },
|
|
115
|
+
status: 200,
|
|
116
|
+
},
|
|
93
117
|
{
|
|
94
118
|
name: "POST /api/orders 买家形状仍由公开路由处理",
|
|
95
119
|
method: "POST",
|
|
@@ -110,10 +134,16 @@ for (const c of cases) {
|
|
|
110
134
|
headers,
|
|
111
135
|
body: c.body === undefined ? undefined : JSON.stringify(c.body),
|
|
112
136
|
});
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
137
|
+
if (c.path.startsWith("/api/qr")) {
|
|
138
|
+
const svg = await r.text();
|
|
139
|
+
assert.strictEqual(r.status, c.status, svg.slice(0, 200));
|
|
140
|
+
assert.ok(svg.includes("<svg"), "二维码应返回 SVG 而不是 401/JSON");
|
|
141
|
+
} else {
|
|
142
|
+
const payload = await r.json();
|
|
143
|
+
assert.strictEqual(r.status, c.status, JSON.stringify(payload));
|
|
144
|
+
if (c.status === 401) assert.strictEqual(payload.error, "API Key Required");
|
|
145
|
+
if (c.status === 400) assert.strictEqual(payload.error, "INVALID_PARAM");
|
|
146
|
+
}
|
|
117
147
|
} finally {
|
|
118
148
|
await closeServer(ctx);
|
|
119
149
|
}
|
package/test-chain-reconcile.js
CHANGED
|
@@ -38,6 +38,7 @@ async function startLcd(handler) {
|
|
|
38
38
|
const out = handler(req.url);
|
|
39
39
|
if (out.error) { res.writeHead(500); return res.end(out.error); }
|
|
40
40
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(out, "raw")) return res.end(JSON.stringify(out.raw));
|
|
41
42
|
return res.end(JSON.stringify({ slots: { slots: out.slots || [] } }));
|
|
42
43
|
}
|
|
43
44
|
res.writeHead(404); res.end();
|
|
@@ -99,6 +100,28 @@ test("链上孤儿 slot → 只记录,不自动创建商品", async () => {
|
|
|
99
100
|
} finally { srv.close(); db.close(); }
|
|
100
101
|
});
|
|
101
102
|
|
|
103
|
+
test("本地已有质押快照时,不被链上调整值覆盖", async () => {
|
|
104
|
+
const db = makeDb();
|
|
105
|
+
db.prepare("INSERT INTO store_products (id, seller_address, title, chain_listed, chain_sku_id, chain_slot_index, chain_stake_per_unit) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
106
|
+
.run("sku-1", SELLER, "sku-1", 1, "sku-1", 0, "2500000");
|
|
107
|
+
const { srv, url } = await startLcd(() => ({ slots: [{ ...activeSlot("sku-1"), stake_per_unit: "5000000" }] }));
|
|
108
|
+
try {
|
|
109
|
+
await runChainReconcile({ db, lcdUrl: url });
|
|
110
|
+
const p = db.prepare("SELECT chain_stake_per_unit FROM store_products WHERE id = 'sku-1'").get();
|
|
111
|
+
assert.strictEqual(p.chain_stake_per_unit, "2500000", "必须保留卖家原始质押快照");
|
|
112
|
+
} finally { srv.close(); db.close(); }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("LCD 返回 200 但形状错误 → degraded,不当成零 slot 成功", async () => {
|
|
116
|
+
const db = makeDb(); insertProduct(db, "sku-1", 1);
|
|
117
|
+
const { srv, url } = await startLcd(() => ({ raw: {} }));
|
|
118
|
+
try {
|
|
119
|
+
const r = await runChainReconcile({ db, lcdUrl: url });
|
|
120
|
+
assert.strictEqual(r.status, "degraded");
|
|
121
|
+
assert.strictEqual(r.errors, 1);
|
|
122
|
+
} finally { srv.close(); db.close(); }
|
|
123
|
+
});
|
|
124
|
+
|
|
102
125
|
test("已经一致 → 不重复 recover", async () => {
|
|
103
126
|
const db = makeDb(); insertProduct(db, "sku-1", 1);
|
|
104
127
|
const { srv, url } = await startLcd(() => ({ slots: [activeSlot("sku-1")] }));
|
|
@@ -7,7 +7,7 @@ const test = require("node:test");
|
|
|
7
7
|
const assert = require("node:assert");
|
|
8
8
|
const Database = require("better-sqlite3");
|
|
9
9
|
const { initChainWriteLogger } = require("./core/chain-write-log");
|
|
10
|
-
const { signAndBroadcast } = require("./routes/chain-writes");
|
|
10
|
+
const { signAndBroadcast, sendTxAs } = require("./routes/chain-writes");
|
|
11
11
|
|
|
12
12
|
test("signAndBroadcast 失败时 chain_write_attempts 也有记录", async () => {
|
|
13
13
|
const db = new Database(":memory:");
|
|
@@ -34,3 +34,29 @@ test("signAndBroadcast 失败时 chain_write_attempts 也有记录", async () =>
|
|
|
34
34
|
assert.ok(row.error.length > 0, "必须保留失败原因");
|
|
35
35
|
db.close();
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
test("sendTxAs 失败时也落审计且 signer 为调用方地址", async () => {
|
|
39
|
+
const db = new Database(":memory:");
|
|
40
|
+
initChainWriteLogger(db);
|
|
41
|
+
const c = {
|
|
42
|
+
signMode: "js",
|
|
43
|
+
feeGranter: "",
|
|
44
|
+
rpcUrl: "http://127.0.0.1:1",
|
|
45
|
+
lcdUrl: "http://127.0.0.1:1",
|
|
46
|
+
home: "/tmp/definitely-not-a-chain-home",
|
|
47
|
+
keyName: "missing-key",
|
|
48
|
+
bin: "definitely-not-a-binary",
|
|
49
|
+
chainId: "test-chain",
|
|
50
|
+
};
|
|
51
|
+
const messages = [{ "@type": "/trustchain.order.v1.MsgCreateOrder", order_id: "order-1" }];
|
|
52
|
+
|
|
53
|
+
await assert.rejects(sendTxAs(c, messages, "tct1seller"), /.*/);
|
|
54
|
+
|
|
55
|
+
const row = db.prepare("SELECT * FROM chain_write_attempts").get();
|
|
56
|
+
assert.ok(row, "sendTxAs 失败也必须有审计记录");
|
|
57
|
+
assert.strictEqual(row.status, "failed");
|
|
58
|
+
assert.strictEqual(row.msg_types, "/trustchain.order.v1.MsgCreateOrder");
|
|
59
|
+
assert.strictEqual(row.signer, "tct1seller");
|
|
60
|
+
assert.ok(row.error.length > 0);
|
|
61
|
+
db.close();
|
|
62
|
+
});
|
package/test-product-type.js
CHANGED
|
@@ -100,28 +100,32 @@ test("① physical 商品: 缺 shipping_address → 400 SHIPPING_ADDRESS_REQUIRE
|
|
|
100
100
|
} finally { srv.close(); }
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
test("② virtual 商品:
|
|
103
|
+
test("② virtual 商品: pending 不交付凭据, paid 后才返回且幂等不变", async () => {
|
|
104
104
|
const { db, srv, base } = await startServer(seedProductsWithType);
|
|
105
105
|
try {
|
|
106
106
|
let r = await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-1" });
|
|
107
107
|
const j = await r.json();
|
|
108
108
|
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
109
|
-
assert.
|
|
110
|
-
assert.
|
|
109
|
+
assert.strictEqual(j.status, "pending");
|
|
110
|
+
assert.strictEqual(j.delivery_credential, "", "待支付不得提前交付凭据");
|
|
111
111
|
assert.strictEqual(j.shipping_address, "", "虚拟单地址为空");
|
|
112
112
|
const row = db.prepare("SELECT * FROM agent_pay_sessions WHERE order_ref = ?").get(j.id);
|
|
113
|
-
assert.
|
|
114
|
-
|
|
113
|
+
assert.match(row.delivery_credential, /^tb-license-tb-[0-9a-f]{16}-[0-9a-f]{4}$/, "license 已在库中生成");
|
|
114
|
+
assert.ok(row.delivery_credential.includes(j.id), "凭据与订单绑定");
|
|
115
|
+
// 幂等重放仍不得提前交付
|
|
115
116
|
r = await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-1" });
|
|
116
117
|
const j2 = await r.json();
|
|
117
118
|
assert.strictEqual(j2.id, j.id);
|
|
118
|
-
assert.strictEqual(j2.delivery_credential,
|
|
119
|
-
//
|
|
119
|
+
assert.strictEqual(j2.delivery_credential, "", "幂等重放也不提前交付");
|
|
120
|
+
// 支付成功后才公开同一凭据
|
|
121
|
+
db.prepare("UPDATE agent_pay_sessions SET state = 'PAID' WHERE order_ref = ?").run(j.id);
|
|
120
122
|
const o = await (await fetch(base + "/api/orders/" + j.id)).json();
|
|
121
|
-
assert.strictEqual(o.
|
|
122
|
-
|
|
123
|
+
assert.strictEqual(o.status, "paid");
|
|
124
|
+
assert.strictEqual(o.delivery_credential, row.delivery_credential, "支付后返回同一凭据");
|
|
125
|
+
// 第二个虚拟单凭据不同
|
|
123
126
|
const j3 = await (await post(base, { product_id: "p-virt-1", quantity: 1, buyer_card: "card_pt2", client_order_no: "V-ORDER-2" })).json();
|
|
124
|
-
assert.notStrictEqual(j3.delivery_credential,
|
|
127
|
+
assert.notStrictEqual(j3.delivery_credential, row.delivery_credential);
|
|
128
|
+
assert.strictEqual(j3.delivery_credential, "", "新单待支付同样不交付");
|
|
125
129
|
} finally { srv.close(); }
|
|
126
130
|
});
|
|
127
131
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* test-sqlite-product-type-preserve.js — 链事件双写不得重置 product_type
|
|
4
|
+
* node --test test-sqlite-product-type-preserve.js
|
|
5
|
+
*/
|
|
6
|
+
const test = require("node:test");
|
|
7
|
+
const assert = require("node:assert");
|
|
8
|
+
const Database = require("better-sqlite3");
|
|
9
|
+
const { SQLitePlugin } = require("./plugins/sqlite");
|
|
10
|
+
|
|
11
|
+
function makeDb() {
|
|
12
|
+
const db = new Database(":memory:");
|
|
13
|
+
db.exec(`CREATE TABLE products (
|
|
14
|
+
sku_id TEXT PRIMARY KEY, seller TEXT, title TEXT, description TEXT, content_hash TEXT,
|
|
15
|
+
price_cny REAL, price_tct TEXT, category_l1 TEXT, category_l2 TEXT, category_l3 TEXT,
|
|
16
|
+
latitude REAL, longitude REAL, city TEXT, stock INTEGER, stake_per_unit TEXT,
|
|
17
|
+
images TEXT, shipping_method TEXT, shipping_fee REAL, delivery_days INTEGER,
|
|
18
|
+
created_at INTEGER, updated_at INTEGER, status TEXT, brand_auth_type INTEGER, insurance_seller TEXT
|
|
19
|
+
)`);
|
|
20
|
+
db.exec(`CREATE TABLE products_fts (rowid INTEGER, title TEXT, description TEXT, category_l1 TEXT, category_l2 TEXT, category_l3 TEXT)`);
|
|
21
|
+
db.exec(`CREATE TABLE store_products (
|
|
22
|
+
id TEXT PRIMARY KEY, seller_address TEXT, title TEXT, description TEXT,
|
|
23
|
+
price_utct INTEGER, stock INTEGER, status TEXT, category TEXT, image_url TEXT,
|
|
24
|
+
tags TEXT, featured INTEGER, sales_count INTEGER, created_at INTEGER, updated_at INTEGER,
|
|
25
|
+
product_type TEXT NOT NULL DEFAULT 'physical'
|
|
26
|
+
)`);
|
|
27
|
+
return db;
|
|
28
|
+
}
|
|
29
|
+
function sampleProduct() {
|
|
30
|
+
return {
|
|
31
|
+
skuId: "sku-virtual", seller: "tct1seller", title: "虚拟商品", description: "",
|
|
32
|
+
contentHash: "hash", priceCny: 1, priceTct: "1",
|
|
33
|
+
category: { level1: "general", level2: "", level3: "" },
|
|
34
|
+
location: { latitude: 0, longitude: 0, city: "" },
|
|
35
|
+
stock: 1, stakePerUnit: "1000000", images: [],
|
|
36
|
+
shipping: { method: "none", fee: 0, deliveryDays: 0 },
|
|
37
|
+
createdAt: 1, updatedAt: 2, status: "active",
|
|
38
|
+
brand: { authType: 0 }, insurance: { insurer: "" },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("已有 virtual 商品经过链事件双写后仍是 virtual", async () => {
|
|
43
|
+
const db = makeDb();
|
|
44
|
+
db.prepare("INSERT INTO store_products (id, seller_address, title, product_type) VALUES (?, ?, ?, ?)")
|
|
45
|
+
.run("sku-virtual", "tct1seller", "虚拟商品", "virtual");
|
|
46
|
+
const plugin = Object.create(SQLitePlugin.prototype);
|
|
47
|
+
plugin.db = db;
|
|
48
|
+
plugin.storeProductsReady = true;
|
|
49
|
+
await plugin.indexProduct(sampleProduct());
|
|
50
|
+
const row = db.prepare("SELECT product_type FROM store_products WHERE id = 'sku-virtual'").get();
|
|
51
|
+
assert.strictEqual(row.product_type, "virtual", "双写不得把 virtual 重置为 physical");
|
|
52
|
+
db.close();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("新商品没有 product_type 时仍按默认 physical 插入", async () => {
|
|
56
|
+
const db = makeDb();
|
|
57
|
+
const plugin = Object.create(SQLitePlugin.prototype);
|
|
58
|
+
plugin.db = db;
|
|
59
|
+
plugin.storeProductsReady = true;
|
|
60
|
+
await plugin.indexProduct(sampleProduct());
|
|
61
|
+
const row = db.prepare("SELECT product_type FROM store_products WHERE id = 'sku-virtual'").get();
|
|
62
|
+
assert.strictEqual(row.product_type, "physical");
|
|
63
|
+
db.close();
|
|
64
|
+
});
|
package/web/dist/app.js
CHANGED
|
@@ -97,7 +97,7 @@ function renderList() {
|
|
|
97
97
|
<div class="product-grid">
|
|
98
98
|
${products.map(p => `
|
|
99
99
|
<div class="product-card" onclick="location.hash='#product/${p.id}'">
|
|
100
|
-
<img class="product-img" src="${p.image_url
|
|
100
|
+
<img class="product-img" src="${safeImageUrl(p.image_url)}" alt="${esc(p.name)}" loading="lazy">
|
|
101
101
|
<div class="product-info">
|
|
102
102
|
<div class="product-name">${esc(p.name)}</div>
|
|
103
103
|
<div class="product-price">¥${fmt(p.price)}</div>
|
|
@@ -126,7 +126,7 @@ function renderDetail(productId) {
|
|
|
126
126
|
<div style="padding:12px 0">
|
|
127
127
|
<button class="btn btn-secondary" style="width:auto;padding:8px 16px;font-size:14px" onclick="location.hash=''">← 返回</button>
|
|
128
128
|
</div>
|
|
129
|
-
<img class="detail-img" src="${p.image_url
|
|
129
|
+
<img class="detail-img" src="${safeImageUrl(p.image_url)}" alt="${esc(p.name)}">
|
|
130
130
|
<div class="detail-name">${esc(p.name)}</div>
|
|
131
131
|
<div class="detail-desc">${esc(p.description || '')}</div>
|
|
132
132
|
<div class="detail-price">¥${fmt(p.price)}</div>
|
|
@@ -258,6 +258,12 @@ function renderOrder(orderId) {
|
|
|
258
258
|
</div>
|
|
259
259
|
</div>
|
|
260
260
|
</div>
|
|
261
|
+
${(order.status === 'paid' || order.status === 'completed') && order.delivery_credential ? `
|
|
262
|
+
<div class="product-card" style="cursor:default;margin-top:16px">
|
|
263
|
+
<div style="font-size:12px;color:var(--muted);margin-bottom:6px">交付凭据</div>
|
|
264
|
+
<code style="word-break:break-all;user-select:all">${esc(order.delivery_credential)}</code>
|
|
265
|
+
</div>
|
|
266
|
+
` : ''}
|
|
261
267
|
<button class="btn btn-secondary" style="margin-top:24px" onclick="location.hash=''">返回首页</button>
|
|
262
268
|
`;
|
|
263
269
|
})
|
|
@@ -377,6 +383,14 @@ function esc(s) {
|
|
|
377
383
|
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
378
384
|
}
|
|
379
385
|
|
|
386
|
+
// 2026-10-18 security: 图片 URL 只允许 https 或站内相对路径,且做属性转义。
|
|
387
|
+
// 防止卖家把 image_url 写成 x" onerror="..." 或 javascript: 形成存储型 XSS。
|
|
388
|
+
function safeImageUrl(url) {
|
|
389
|
+
const s = String(url || '').trim();
|
|
390
|
+
if (/^https:\/\//i.test(s) || /^\/(?!\/)/.test(s)) return esc(s);
|
|
391
|
+
return '/icon.svg';
|
|
392
|
+
}
|
|
393
|
+
|
|
380
394
|
function fmt(n) {
|
|
381
395
|
return Number(n || 0).toFixed(2);
|
|
382
396
|
}
|
package/web/dist/index.html
CHANGED
package/web/dist/sw.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// TrustBase 示例店铺 Service Worker (pwa-kit 生成风格的手写版)
|
|
2
|
-
const CACHE = 'shop-demo-
|
|
2
|
+
const CACHE = 'shop-demo-v6'; // 2026-10-18: 普通 script + 错误边界 + 图片 XSS 修复,强制刷新旧缓存
|
|
3
3
|
const CORE = ['/', '/app.js', '/manifest.json', '/icon.svg'];
|
|
4
4
|
|
|
5
5
|
self.addEventListener('install', (e) => {
|