trustbase-seller-backend 0.2.6 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -148,9 +148,8 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
148
148
  <div id="restore-msg" style="font-size:13px;margin-top:6px"></div>
149
149
  </div>
150
150
  <div id="backup-quiz" style="display:none;margin-top:10px;background:#FCFAF4;border:1px solid #EAE3D6;border-radius:12px;padding:14px 16px">
151
- <div class="desc" style="margin-bottom:4px">确认你真的抄好了 — 请按安全卡上抄写的填写 (答错不会提示正确答案, 可重新抽题):</div>
151
+ <div class="desc" style="margin-bottom:4px">确认你真的抄好了 — 分 3 步, 一次填一个词 (答错不会提示正确答案):</div>
152
152
  <div id="backup-questions"></div>
153
- <button class="sbtn primary" style="margin-top:10px" onclick="checkBackupVerify()">提交验证</button>
154
153
  </div>
155
154
  <div id="backup-result"></div>
156
155
  </div>
@@ -416,47 +415,74 @@ function renderProgress(s){
416
415
  // 当前可做项高亮 (备份验证已过 → 持久打勾 (后端 tb_backup_ack 台账); 否则标当前可做)
417
416
  if(s.backup_acked){ markStepDone('st-backup'); } else { document.getElementById('st-backup').className='step current'; }
418
417
  }
419
- // ---- 纸密钥备份回填验证 (2026-10-03, MetaMask 式抽词; 原则: mnemonic 不出本机) ----
420
- // 首次创建 (助记词还在页面内存 _mn) → 纯前端比对, 不调网络;
421
- // 页面刷新后 → POST /api/onboard/verify-backup 本机后端比对 (只送位置+词, 后端永不返回正确词)。
422
- var _bqTries=0;
418
+ // ---- 店铺安全卡回填验证 (2026-10-03, MetaMask 式抽词; 原则: 安全词不出本机) ----
419
+ // 分步式 (xiaomu 拍板): 抽 3 词位, 一次只问一个词, 每步单独验证 (即时反馈, 答错不累积)。
420
+ // 首次创建 (安全词还在页面内存 _mn) → 纯前端比对, 不调网络;
421
+ // 页面刷新后 → POST /api/onboard/verify-backup 单元素 positions (端点 ≤5 支持), 本机后端比对。
422
+ var _bq={positions:[],idx:0,wrong:0};
423
423
  function startBackupVerify(){
424
424
  // 随机抽 3 个词位 (1-24 不重复, 升序展示)
425
425
  var pool=[]; for(var i=1;i<=24;i++) pool.push(i);
426
426
  var pos=[];
427
427
  while(pos.length<3){ pos.push(pool.splice(Math.floor(Math.random()*pool.length),1)[0]); }
428
428
  pos.sort(function(a,b){return a-b;});
429
- var q=document.getElementById('backup-questions');
430
- q.innerHTML=pos.map(function(p){
431
- return '<label style="display:block;font-size:13px;color:#6B655B;margin:8px 0 4px">请按安全卡上抄写的填写第 '+p+' 个词</label>'
432
- +'<input class="bq-word" data-pos="'+p+'" placeholder="第 '+p+' 个词" autocapitalize="off" autocomplete="off" spellcheck="false" style="width:100%;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:15px;background:#fff">';
433
- }).join('');
429
+ _bq={positions:pos,idx:0,wrong:0};
434
430
  document.getElementById('backup-result').innerHTML='';
435
- var box=document.getElementById('backup-quiz');
436
- box.style.display='block';
437
- box.scrollIntoView({behavior:'smooth',block:'center'});
431
+ document.getElementById('backup-quiz').style.display='block';
432
+ renderBqStep();
433
+ }
434
+ function renderBqStep(){
435
+ var p=_bq.positions[_bq.idx];
436
+ var q=document.getElementById('backup-questions');
437
+ q.innerHTML='<div style="font-size:12px;color:#8A857B;margin-bottom:6px">第 '+(_bq.idx+1)+'/3 步</div>'
438
+ +'<label style="display:block;font-size:13px;color:#6B655B;margin:0 0 4px">请按安全卡上抄写的填写第 '+p+' 个词</label>'
439
+ +'<input class="bq-word" id="bq-input" data-pos="'+p+'" placeholder="第 '+p+' 个词" autocapitalize="off" autocomplete="off" spellcheck="false" style="width:100%;padding:12px;font-size:16px;border:1px solid #D4CDBF;border-radius:8px;background:#fff">'
440
+ +'<div style="display:flex;gap:8px;margin-top:10px">'
441
+ +'<button class="sbtn primary" style="flex:1;padding:12px;font-size:15px" onclick="checkBqStep()">'+(_bq.idx<2?'下一步':'完成验证')+'</button>'
442
+ +'<button class="sbtn" onclick="startBackupVerify()">返回重抽</button></div>'
443
+ +(_bq.wrong>=3?'<div style="margin-top:8px"><button class="sbtn" onclick="swapBqPosition()">这个词位填不对? 换一个词位</button></div>':'');
444
+ var inp=document.getElementById('bq-input');
445
+ inp.focus();
446
+ inp.onkeydown=function(e){ if(e.key==='Enter') checkBqStep(); };
438
447
  }
439
- async function checkBackupVerify(){
440
- var inputs=document.querySelectorAll('.bq-word');
441
- var positions=[], words=[];
442
- inputs.forEach(function(el){ positions.push(Number(el.getAttribute('data-pos'))); words.push(el.value); });
443
- if(words.some(function(w){return !w.trim();})) return backupResult(false,'三个词都要填');
444
- // ① 内存路径: 助记词还在 (_mn) → 纯前端比对 (更快, 零网络)
448
+ async function checkBqStep(){
449
+ var inp=document.getElementById('bq-input');
450
+ var w=(inp.value||'').trim();
451
+ if(!w) return backupResult(false,'请先填写这个词');
452
+ var p=Number(inp.getAttribute('data-pos'));
453
+ var correct=false;
454
+ // ① 内存路径: 安全词还在 (_mn) → 纯前端比对 (更快, 零网络)
445
455
  if(_mn){
446
- var real=_mn.split(/\s+/);
447
- var wrong=[];
448
- positions.forEach(function(p,i){ if(words[i].trim().toLowerCase()!==real[p-1].toLowerCase()) wrong.push(p); });
449
- if(!wrong.length){ await persistBackupAck(); return backupOk(); }
450
- return backupWrong(wrong);
456
+ correct = _mn.split(/\s+/)[p-1].toLowerCase()===w.toLowerCase();
457
+ }else{
458
+ // ② 后端路径: 刷新后内存没有 → 本机后端比对 (安全词不出本机, 只送位置+词)
459
+ try{
460
+ var j=await readJson(await fetch('/api/onboard/verify-backup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({positions:[p],words:[w]})}));
461
+ if(j.ok) correct=true;
462
+ else if(j.error==='rate_limited') return backupResult(false, j.message||'操作太频繁, 请稍后再试');
463
+ else if(j.wrong_positions&&j.wrong_positions.length) correct=false;
464
+ else return backupResult(false, j.message||'验证失败, 请稍后再试');
465
+ }catch(e){ return backupResult(false,'服务暂时不可用,请刷新重试 ('+e.message+')'); }
466
+ }
467
+ if(correct){
468
+ _bq.idx++; _bq.wrong=0;
469
+ document.getElementById('backup-result').innerHTML='';
470
+ if(_bq.idx>=3){ await persistBackupAck(); return backupOk(); }
471
+ renderBqStep();
472
+ }else{
473
+ _bq.wrong++;
474
+ backupResult(false,'⚠️ 这个词不对 (不会提示正确答案), 请对照安全卡重新填');
475
+ if(_bq.wrong>=3) renderBqStep(); // 同一词位错 3 次 → 出现"换一个词位"
476
+ else { inp.value=''; inp.focus(); }
451
477
  }
452
- // ② 后端路径: 刷新后内存没有 → 本机后端比对 (mnemonic 不出本机)
453
- try{
454
- var j=await readJson(await fetch('/api/onboard/verify-backup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({positions:positions,words:words})}));
455
- if(j.ok) return backupOk();
456
- if(j.error==='rate_limited') return backupResult(false, j.message||'30 秒内只能验证一次, 请稍后再试');
457
- if(j.wrong_positions&&j.wrong_positions.length) return backupWrong(j.wrong_positions);
458
- backupResult(false, j.message||'验证失败, 请稍后再试');
459
- }catch(e){ backupResult(false,'服务暂时不可用,请刷新重试 ('+e.message+')'); }
478
+ }
479
+ function swapBqPosition(){
480
+ // 换掉当前词位 (与其他 2 个不重复), 错次清零
481
+ var cand;
482
+ do{ cand=1+Math.floor(Math.random()*24); }while(_bq.positions.indexOf(cand)>=0);
483
+ _bq.positions[_bq.idx]=cand; _bq.wrong=0;
484
+ document.getElementById('backup-result').innerHTML='';
485
+ renderBqStep();
460
486
  }
461
487
  async function persistBackupAck(){
462
488
  try{ await fetch('/api/onboard/backup-ack',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); }catch(e){}
@@ -465,14 +491,7 @@ function backupOk(){
465
491
  document.getElementById('backup-quiz').style.display='none';
466
492
  markStepDone('st-backup'); // 步骤②打勾 (后端台账已持久化, 换浏览器仍绿)
467
493
  backupResult(true,'✅ 备份已确认 — 你的店铺安全卡只有这一份, 请收好');
468
- _bqTries=0;
469
- }
470
- function backupWrong(wrong){
471
- _bqTries++;
472
- var tip='⚠️ 第 '+wrong.join('、')+' 个词不对 — 请对照安全卡检查 (不会提示正确答案), 已重新抽题';
473
- if(_bqTries>=3) tip='⚠️ 第 '+wrong.join('、')+' 个词不对; 已错 3 次, 建议点上方"重新打印安全卡"逐词对照后再试';
474
- startBackupVerify(); // 重新抽题
475
- backupResult(false, tip);
494
+ _bq={positions:[],idx:0,wrong:0};
476
495
  }
477
496
  function backupResult(ok, text){
478
497
  document.getElementById('backup-result').innerHTML=
package/routes/onboard.js CHANGED
@@ -478,7 +478,7 @@ function createOnboardRouter(deps) {
478
478
  db.prepare("INSERT INTO tb_backup_ack (id, acked_at) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET acked_at=excluded.acked_at")
479
479
  .run(Math.floor(Date.now() / 1000));
480
480
  }
481
- const backupVerifyRate = new Map(); // ip → last attempt ms (单商户本机场景, 内存即可)
481
+ const backupVerifyRate = new Map(); // ip → 30s 窗口内的尝试时间戳[] (分步式验证一步一调, 6 次/30s 足够 3 步 + 重试)
482
482
  router.post("/verify-backup", (req, res) => {
483
483
  try {
484
484
  const b = req.body || {};
@@ -492,11 +492,13 @@ function createOnboardRouter(deps) {
492
492
  return res.status(400).json({ ok: false, error: "invalid_param", message: "positions 必须是 1-24 的整数" });
493
493
  }
494
494
  const ip = req.ip || "local";
495
- const last = backupVerifyRate.get(ip) || 0;
496
- if (Date.now() - last < 30000) {
497
- return res.status(429).json({ ok: false, error: "rate_limited", message: "30 秒内只能验证一次, 请对照纸密钥稍后再试" });
495
+ const nowMs = Date.now();
496
+ const arr = (backupVerifyRate.get(ip) || []).filter((t) => nowMs - t < 30000);
497
+ if (arr.length >= 6) {
498
+ return res.status(429).json({ ok: false, error: "rate_limited", message: "验证太频繁 (30 秒内最多 6 次), 请对照安全卡稍后再试" });
498
499
  }
499
- backupVerifyRate.set(ip, Date.now());
500
+ arr.push(nowMs);
501
+ backupVerifyRate.set(ip, arr);
500
502
  let realWords;
501
503
  try {
502
504
  realWords = fs.readFileSync(basePaths.mnemonicFile(), "utf8").trim().split(/\s+/);
@@ -83,15 +83,18 @@ test("verify-backup: 错词 → wrong_positions (不泄正确答案) + 大小写
83
83
  } finally { srv.close(); }
84
84
  });
85
85
 
86
- test("verify-backup: 限流 30s (同 IP 连续两次 → 第二次 429)", async () => {
86
+ test("verify-backup: 限流滑动窗口 6 次/30s (分步验证一步一调, 3 步+重试够用)", async () => {
87
87
  const { srv, base } = await startServer();
88
88
  try {
89
- const body = JSON.stringify({ positions: [1, 2, 3], words: ["economy", "stock", "theory"] });
90
- const r1 = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
91
- assert.strictEqual(r1.status, 200);
92
- const r2 = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
93
- assert.strictEqual(r2.status, 429);
94
- assert.strictEqual((await r2.json()).error, "rate_limited");
89
+ // 单元素 positions (分步式每步一词)
90
+ const body = JSON.stringify({ positions: [1], words: ["economy"] });
91
+ for (let i = 0; i < 6; i++) {
92
+ const r = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
93
+ assert.strictEqual(r.status, 200, "第 " + (i + 1) + " 次放行");
94
+ }
95
+ const r7 = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
96
+ assert.strictEqual(r7.status, 429, "第 7 次 (30s 窗口内) 拒");
97
+ assert.strictEqual((await r7.json()).error, "rate_limited");
95
98
  } finally { srv.close(); }
96
99
  });
97
100
 
@@ -120,7 +123,7 @@ test("backup-ack: 纯前端比对路径落账 (不带密钥材料) + status done
120
123
  } finally { srv.close(); }
121
124
  });
122
125
 
123
- test("页面含备份验证 UI (抽题/提交/结果显示) + 内存路径分支", async () => {
126
+ test("页面含备份验证 UI (分步式: 单词位输入框/下一步/进度提示/换词位) + 内存路径分支", async () => {
124
127
  const { srv, base } = await startServer();
125
128
  try {
126
129
  const html = await (await fetch(base + "/onboard/")).text();
@@ -129,6 +132,13 @@ test("页面含备份验证 UI (抽题/提交/结果显示) + 内存路径分支
129
132
  assert.ok(html.includes("/api/onboard/verify-backup"), "后端比对路径");
130
133
  assert.ok(html.includes("persistBackupAck"), "内存路径 ack 落账");
131
134
  assert.ok(html.includes("我已抄好"), "大白话文案");
135
+ // 分步式 (2026-10-03): 一次一词 + 下一步 + 进度 + 重抽/换词位
136
+ assert.ok(html.includes("checkBqStep"), "单步验证函数");
137
+ assert.ok(html.includes("下一步"), "下一步按钮");
138
+ assert.ok(html.includes("/3 步"), "进度提示");
139
+ assert.ok(html.includes("返回重抽"), "返回重抽");
140
+ assert.ok(html.includes("swapBqPosition"), "换一个词位");
141
+ assert.ok(!html.includes("checkBackupVerify"), "旧一次性提交函数已移除");
132
142
  } finally { srv.close(); }
133
143
  });
134
144