@seasonkoh/webaz 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +156 -20
  3. package/dist/layer0-foundation/L0-1-database/schema.js +5 -4
  4. package/dist/layer0-foundation/L0-2-state-machine/engine.js +228 -7
  5. package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +156 -0
  6. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +53 -12
  7. package/dist/layer0-foundation/L0-5-manifest/manifest.js +14 -1
  8. package/dist/layer1-agent/L1-1-mcp-server/auth.js +1 -1
  9. package/dist/layer1-agent/L1-1-mcp-server/server.js +3543 -852
  10. package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +324 -0
  11. package/dist/layer1-agent/L1-2-identity/agent-passport.js +100 -0
  12. package/dist/layer2-business/L2-6-notifications/notification-engine.js +72 -5
  13. package/dist/layer2-business/L2-7-snf/snf-engine.js +287 -0
  14. package/dist/layer2-business/L2-anchor-registry/anchor-registry.js +396 -0
  15. package/dist/layer2-business/L2-notes/note-photo-storage.js +133 -0
  16. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +6 -6
  17. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +246 -0
  18. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +95 -1
  19. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +31 -2
  20. package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +358 -0
  21. package/dist/pwa/public/app.js +31230 -2345
  22. package/dist/pwa/public/i18n.js +5282 -111
  23. package/dist/pwa/public/icon.svg +11 -0
  24. package/dist/pwa/public/index.html +4 -1
  25. package/dist/pwa/public/manifest.json +39 -4
  26. package/dist/pwa/public/openapi.json +5946 -0
  27. package/dist/pwa/public/style.css +278 -5
  28. package/dist/pwa/public/sw.js +41 -2
  29. package/dist/pwa/public/vendor/jsQR.js +10102 -0
  30. package/dist/pwa/public/webaz-logo.png +0 -0
  31. package/dist/pwa/routes/account-deletion.js +53 -0
  32. package/dist/pwa/routes/addresses.js +105 -0
  33. package/dist/pwa/routes/admin-admins.js +151 -0
  34. package/dist/pwa/routes/admin-analytics.js +253 -0
  35. package/dist/pwa/routes/admin-atomic.js +21 -0
  36. package/dist/pwa/routes/admin-catalog.js +64 -0
  37. package/dist/pwa/routes/admin-editor-picks.js +45 -0
  38. package/dist/pwa/routes/admin-events.js +60 -0
  39. package/dist/pwa/routes/admin-health.js +66 -0
  40. package/dist/pwa/routes/admin-moderation.js +120 -0
  41. package/dist/pwa/routes/admin-ops.js +179 -0
  42. package/dist/pwa/routes/admin-protocol-params.js +79 -0
  43. package/dist/pwa/routes/admin-reports.js +154 -0
  44. package/dist/pwa/routes/admin-tokenomics.js +113 -0
  45. package/dist/pwa/routes/admin-users-lifecycle.js +237 -0
  46. package/dist/pwa/routes/admin-users-query.js +390 -0
  47. package/dist/pwa/routes/admin-verifier-flow.js +126 -0
  48. package/dist/pwa/routes/admin-verifier-whitelist.js +111 -0
  49. package/dist/pwa/routes/admin-wallet-ops.js +66 -0
  50. package/dist/pwa/routes/agent-buy.js +215 -0
  51. package/dist/pwa/routes/agent-governance.js +341 -0
  52. package/dist/pwa/routes/agent-reputation.js +34 -0
  53. package/dist/pwa/routes/ai.js +101 -0
  54. package/dist/pwa/routes/analytics.js +272 -0
  55. package/dist/pwa/routes/anchors.js +169 -0
  56. package/dist/pwa/routes/announcements.js +110 -0
  57. package/dist/pwa/routes/arbitrator.js +117 -0
  58. package/dist/pwa/routes/auction.js +436 -0
  59. package/dist/pwa/routes/auth-login.js +40 -0
  60. package/dist/pwa/routes/auth-read.js +66 -0
  61. package/dist/pwa/routes/auth-register.js +138 -0
  62. package/dist/pwa/routes/auth-sessions.js +62 -0
  63. package/dist/pwa/routes/blocklist.js +60 -0
  64. package/dist/pwa/routes/buyer-feeds.js +224 -0
  65. package/dist/pwa/routes/cart.js +155 -0
  66. package/dist/pwa/routes/charity.js +816 -0
  67. package/dist/pwa/routes/chat.js +318 -0
  68. package/dist/pwa/routes/checkin-tasks.js +122 -0
  69. package/dist/pwa/routes/checkout-helpers.js +85 -0
  70. package/dist/pwa/routes/claim-initiators.js +88 -0
  71. package/dist/pwa/routes/claim-verify.js +615 -0
  72. package/dist/pwa/routes/claim-voting.js +114 -0
  73. package/dist/pwa/routes/claim-withdrawals.js +20 -0
  74. package/dist/pwa/routes/coupons.js +165 -0
  75. package/dist/pwa/routes/dashboards.js +99 -0
  76. package/dist/pwa/routes/dispute-cases.js +267 -0
  77. package/dist/pwa/routes/disputes-read.js +358 -0
  78. package/dist/pwa/routes/disputes-write.js +475 -0
  79. package/dist/pwa/routes/evidence.js +86 -0
  80. package/dist/pwa/routes/external-anchors.js +107 -0
  81. package/dist/pwa/routes/feedback.js +270 -0
  82. package/dist/pwa/routes/flash-sales.js +130 -0
  83. package/dist/pwa/routes/follows.js +103 -0
  84. package/dist/pwa/routes/group-buys.js +208 -0
  85. package/dist/pwa/routes/growth.js +199 -0
  86. package/dist/pwa/routes/import-product.js +153 -0
  87. package/dist/pwa/routes/kyc.js +40 -0
  88. package/dist/pwa/routes/leaderboard.js +149 -0
  89. package/dist/pwa/routes/listings.js +281 -0
  90. package/dist/pwa/routes/logistics.js +35 -0
  91. package/dist/pwa/routes/manifests.js +126 -0
  92. package/dist/pwa/routes/me-data.js +101 -0
  93. package/dist/pwa/routes/notifications.js +48 -0
  94. package/dist/pwa/routes/offers.js +96 -0
  95. package/dist/pwa/routes/orders-action.js +285 -0
  96. package/dist/pwa/routes/orders-create.js +339 -0
  97. package/dist/pwa/routes/orders-read.js +180 -0
  98. package/dist/pwa/routes/p2p-products.js +178 -0
  99. package/dist/pwa/routes/payments-governance.js +311 -0
  100. package/dist/pwa/routes/peers.js +34 -0
  101. package/dist/pwa/routes/pin-receipts.js +39 -0
  102. package/dist/pwa/routes/products-aliases.js +119 -0
  103. package/dist/pwa/routes/products-claims.js +60 -0
  104. package/dist/pwa/routes/products-create.js +206 -0
  105. package/dist/pwa/routes/products-crud.js +73 -0
  106. package/dist/pwa/routes/products-links.js +129 -0
  107. package/dist/pwa/routes/products-list.js +424 -0
  108. package/dist/pwa/routes/products-meta.js +155 -0
  109. package/dist/pwa/routes/products-update.js +125 -0
  110. package/dist/pwa/routes/profile-credentials.js +105 -0
  111. package/dist/pwa/routes/profile-identity.js +174 -0
  112. package/dist/pwa/routes/profile-location.js +35 -0
  113. package/dist/pwa/routes/profile-placement.js +70 -0
  114. package/dist/pwa/routes/profile-prefs.js +93 -0
  115. package/dist/pwa/routes/promoter.js +208 -0
  116. package/dist/pwa/routes/public-utils.js +170 -0
  117. package/dist/pwa/routes/push.js +54 -0
  118. package/dist/pwa/routes/ratings.js +220 -0
  119. package/dist/pwa/routes/recover-key.js +100 -0
  120. package/dist/pwa/routes/referral.js +58 -0
  121. package/dist/pwa/routes/reputation.js +34 -0
  122. package/dist/pwa/routes/returns.js +493 -0
  123. package/dist/pwa/routes/reviews.js +81 -0
  124. package/dist/pwa/routes/rfqs.js +443 -0
  125. package/dist/pwa/routes/search.js +172 -0
  126. package/dist/pwa/routes/secondhand.js +278 -0
  127. package/dist/pwa/routes/seller-quota.js +225 -0
  128. package/dist/pwa/routes/share-redirects.js +164 -0
  129. package/dist/pwa/routes/shareables-interactions.js +212 -0
  130. package/dist/pwa/routes/shareables.js +470 -0
  131. package/dist/pwa/routes/shops.js +98 -0
  132. package/dist/pwa/routes/signaling.js +43 -0
  133. package/dist/pwa/routes/skill-market.js +173 -0
  134. package/dist/pwa/routes/skills.js +174 -0
  135. package/dist/pwa/routes/snf.js +126 -0
  136. package/dist/pwa/routes/tags.js +47 -0
  137. package/dist/pwa/routes/trial.js +333 -0
  138. package/dist/pwa/routes/trusted-kpi.js +87 -0
  139. package/dist/pwa/routes/url-claim.js +113 -0
  140. package/dist/pwa/routes/users-public.js +317 -0
  141. package/dist/pwa/routes/variants.js +156 -0
  142. package/dist/pwa/routes/verifier-user.js +107 -0
  143. package/dist/pwa/routes/verify-tasks.js +120 -0
  144. package/dist/pwa/routes/waitlist.js +65 -0
  145. package/dist/pwa/routes/wallet-read.js +218 -0
  146. package/dist/pwa/routes/wallet-write.js +273 -0
  147. package/dist/pwa/routes/webauthn.js +188 -0
  148. package/dist/pwa/routes/webhooks.js +162 -0
  149. package/dist/pwa/routes/welcome.js +226 -0
  150. package/dist/pwa/routes/wishlist-qa.js +135 -0
  151. package/dist/pwa/security/ssrf.js +110 -0
  152. package/dist/pwa/server.js +9247 -2097
  153. package/package.json +8 -3
@@ -0,0 +1,273 @@
1
+ import { verifyMessage } from 'viem';
2
+ export function registerWalletWriteRoutes(app, deps) {
3
+ const { db, auth, isTrustedRole, generateId, getProtocolParam, consumeGateToken, issueCode, findActiveCode, maskEmail, LARGE_WITHDRAW_THRESHOLD } = deps;
4
+ // Wave G-1: 签名挑战 — 5min 一次性 nonce
5
+ const walletChallenges = new Map();
6
+ const cleanupWalletChallenges = () => {
7
+ const now = Date.now();
8
+ for (const [k, v] of walletChallenges)
9
+ if (v.expiresAt < now)
10
+ walletChallenges.delete(k);
11
+ };
12
+ app.post('/api/wallet/connect/challenge', (req, res) => {
13
+ const user = auth(req, res);
14
+ if (!user)
15
+ return;
16
+ if (isTrustedRole(user))
17
+ return void res.status(403).json({ error: '受信角色无钱包' });
18
+ cleanupWalletChallenges();
19
+ const nonce = generateId('nce').replace(/[^a-zA-Z0-9]/g, '').slice(0, 16) + Date.now().toString(36);
20
+ const id = generateId('chl');
21
+ walletChallenges.set(id, { userId: String(user.id), nonce, expiresAt: Date.now() + 5 * 60_000 });
22
+ const message = `WebAZ 钱包绑定验证\n\nNonce: ${nonce}\nUserID: ${user.id}\nExpires: ${new Date(Date.now() + 5 * 60_000).toISOString()}\n\n签名仅用于证明地址归属,不消耗任何 gas,也不会触发任何链上交易。`;
23
+ res.json({ challenge_id: id, message });
24
+ });
25
+ app.post('/api/wallet/connect/verify', async (req, res) => {
26
+ const user = auth(req, res);
27
+ if (!user)
28
+ return;
29
+ // H-2 P1-1: 防御性 — 受信角色不应能绑钱包
30
+ if (isTrustedRole(user))
31
+ return void res.status(403).json({ error: '受信角色无钱包' });
32
+ cleanupWalletChallenges();
33
+ const { challenge_id, address, signature, label } = req.body || {};
34
+ if (!challenge_id || !address || !signature) {
35
+ return void res.status(400).json({ error: '缺少 challenge_id / address / signature' });
36
+ }
37
+ if (!/^0x[0-9a-fA-F]{40}$/.test(String(address))) {
38
+ return void res.status(400).json({ error: '地址格式无效' });
39
+ }
40
+ const chl = walletChallenges.get(String(challenge_id));
41
+ if (!chl)
42
+ return void res.status(400).json({ error: 'challenge 无效或已过期' });
43
+ if (chl.userId !== String(user.id))
44
+ return void res.status(403).json({ error: 'challenge 不属于当前用户' });
45
+ walletChallenges.delete(String(challenge_id)); // 单次使用
46
+ const message = `WebAZ 钱包绑定验证\n\nNonce: ${chl.nonce}\nUserID: ${user.id}\nExpires: ${new Date(chl.expiresAt).toISOString()}\n\n签名仅用于证明地址归属,不消耗任何 gas,也不会触发任何链上交易。`;
47
+ // viem verifyMessage 自动处理 EIP-191 personal_sign 格式
48
+ try {
49
+ const valid = await verifyMessage({
50
+ address: address,
51
+ message,
52
+ signature: signature,
53
+ });
54
+ if (!valid)
55
+ return void res.status(400).json({ error: '签名验证失败' });
56
+ }
57
+ catch (e) {
58
+ return void res.status(400).json({ error: '签名格式错误: ' + e.message });
59
+ }
60
+ // 已通过签名校验 → 加入白名单,免 24h 冷却(activates_at = NOW)
61
+ const addrLc = String(address).toLowerCase();
62
+ const existing = db.prepare('SELECT id, revoked_at FROM withdrawal_whitelist WHERE user_id = ? AND address = ?').get(user.id, addrLc);
63
+ if (existing) {
64
+ db.prepare(`UPDATE withdrawal_whitelist SET activates_at = datetime('now'), revoked_at = NULL,
65
+ signature_verified_at = datetime('now'), label = COALESCE(?, label) WHERE id = ?`)
66
+ .run(label || null, existing.id);
67
+ return void res.json({ success: true, id: existing.id, activated: true });
68
+ }
69
+ const id = generateId('wl');
70
+ db.prepare(`INSERT INTO withdrawal_whitelist (id, user_id, address, label, activates_at, signature_verified_at)
71
+ VALUES (?,?,?,?,datetime('now'),datetime('now'))`)
72
+ .run(id, user.id, addrLc, label ? String(label).slice(0, 30) : null);
73
+ res.json({ success: true, id, activated: true });
74
+ });
75
+ // 提现申请
76
+ app.post('/api/wallet/withdraw', (req, res) => {
77
+ const user = auth(req, res);
78
+ if (!user)
79
+ return;
80
+ if (isTrustedRole(user))
81
+ return void res.status(403).json({ error: '受信角色无钱包,不可提现', error_code: 'TRUSTED_ROLE_NO_WALLET' });
82
+ const { to_address: to_address_raw, amount } = req.body;
83
+ // P0-1: toLowerCase 后与白名单匹配
84
+ const to_address = typeof to_address_raw === 'string' ? to_address_raw.toLowerCase() : to_address_raw;
85
+ if (!/^0x[0-9a-fA-F]{40}$/.test(to_address ?? '')) {
86
+ return void res.json({ error: '请输入有效的以太坊地址(0x 开头,42 位字符)' });
87
+ }
88
+ const amountNum = Number(amount);
89
+ if (!amountNum || amountNum <= 0)
90
+ return void res.json({ error: '请输入提现金额' });
91
+ const minWithdraw = getProtocolParam('usdc_min_withdraw_waz', 10);
92
+ if (amountNum < minWithdraw)
93
+ return void res.json({ error: `最低提现金额为 ${minWithdraw} WAZ` });
94
+ // 2026-05-22 audit P1:大额提现 KYC 强制(双维度防 smurf 分拆)
95
+ const kycThreshold = getProtocolParam('kyc_required_withdraw_waz', 1000);
96
+ const kycDailyThreshold = getProtocolParam('kyc_daily_cumulative_waz', 3000);
97
+ let kycReason = null;
98
+ let kycField = 'single';
99
+ if (amountNum >= kycThreshold) {
100
+ kycReason = `提现 ≥ ${kycThreshold} WAZ 需要先完成实名认证(KYC)`;
101
+ kycField = 'single';
102
+ }
103
+ else {
104
+ const dailyRow = db.prepare(`
105
+ SELECT COALESCE(SUM(amount), 0) as total
106
+ FROM withdrawal_requests
107
+ WHERE user_id = ?
108
+ AND status IN ('pending', 'processing', 'completed', 'awaiting_email_confirm')
109
+ AND created_at > datetime('now', '-1 day')
110
+ `).get(user.id);
111
+ const dailyTotal = Number(dailyRow.total) + amountNum;
112
+ if (dailyTotal >= kycDailyThreshold) {
113
+ kycReason = `24h 内提现累计 ${dailyTotal.toFixed(2)} ≥ ${kycDailyThreshold} WAZ,需先完成实名认证(防 smurf)`;
114
+ kycField = 'daily_cumulative';
115
+ }
116
+ }
117
+ if (kycReason) {
118
+ const k = db.prepare("SELECT status FROM kyc_records WHERE user_id = ?").get(user.id);
119
+ if (!k || k.status !== 'approved') {
120
+ return void res.status(403).json({
121
+ error: kycReason,
122
+ error_code: 'KYC_REQUIRED_FOR_WITHDRAW',
123
+ trigger: kycField,
124
+ threshold: kycField === 'single' ? kycThreshold : kycDailyThreshold,
125
+ });
126
+ }
127
+ }
128
+ // WebAuthn gate
129
+ // opt-in webauthn_required_for_withdraw → 所有金额都要 Passkey
130
+ // #1009 大额自动强制 — 已注册 Passkey + 金额 > LARGE_WITHDRAW_THRESHOLD 一律走 Passkey
131
+ // 未注册 Passkey 的用户走原邮件确认路径(不强制注册,避免新用户卡死)
132
+ const hasPasskeyRow = db.prepare('SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?').get(user.id);
133
+ const hasPasskey = (hasPasskeyRow?.n || 0) > 0;
134
+ const forceWebauthnByAmount = amountNum > LARGE_WITHDRAW_THRESHOLD && hasPasskey;
135
+ if (user.webauthn_required_for_withdraw || forceWebauthnByAmount) {
136
+ const token = req.headers['x-webauthn-token'];
137
+ const gate = consumeGateToken(user.id, token, 'withdraw', (data) => {
138
+ const d = (data || {});
139
+ return d.to_address === to_address && Number(d.amount) === amountNum;
140
+ });
141
+ if (!gate.ok) {
142
+ return void res.status(403).json({
143
+ error: gate.reason,
144
+ webauthn_required: true,
145
+ purpose: 'withdraw',
146
+ purpose_data: { to_address, amount: amountNum },
147
+ force_reason: forceWebauthnByAmount && !user.webauthn_required_for_withdraw
148
+ ? `large_withdraw_auto:${LARGE_WITHDRAW_THRESHOLD}` : 'user_opted_in',
149
+ });
150
+ }
151
+ }
152
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
153
+ if (!wallet)
154
+ return void res.status(500).json({ error: '钱包记录缺失', error_code: 'WALLET_MISSING' });
155
+ if (wallet.balance < amountNum) {
156
+ return void res.json({ error: `余额不足:当前可用 ${wallet.balance.toFixed(2)} WAZ` });
157
+ }
158
+ // 白名单校验:必须在 user 的 active 白名单内,且过了 24h 冷却
159
+ const wl = db.prepare(`
160
+ SELECT activates_at FROM withdrawal_whitelist
161
+ WHERE user_id = ? AND address = ? AND revoked_at IS NULL
162
+ `).get(user.id, to_address);
163
+ if (!wl) {
164
+ return void res.json({ error: '该地址不在你的白名单中,请先到「提现白名单」添加(添加后 24h 冷却生效)' });
165
+ }
166
+ if (new Date(wl.activates_at.replace(' ', 'T') + 'Z').getTime() > Date.now()) {
167
+ const mins = Math.ceil((new Date(wl.activates_at.replace(' ', 'T') + 'Z').getTime() - Date.now()) / 60_000);
168
+ return void res.json({ error: `该地址在冷却期内,约 ${mins} 分钟后可用(添加后 24h 强制冷却)` });
169
+ }
170
+ // 大额提现 → 强制邮件确认
171
+ const isLarge = amountNum > LARGE_WITHDRAW_THRESHOLD;
172
+ if (isLarge) {
173
+ if (!user.email_verified || !user.email) {
174
+ return void res.json({ error: `大额提现(> ${LARGE_WITHDRAW_THRESHOLD} WAZ)需先绑定邮箱用于二次确认` });
175
+ }
176
+ // 不立即扣款 + 不写入正式 pending — 进入 pending_email 阶段,待 confirm
177
+ const wid = generateId('wdr');
178
+ db.prepare(`INSERT INTO withdrawal_requests (id, user_id, to_address, amount, status, status_detail)
179
+ VALUES (?,?,?,?,?,?)`)
180
+ .run(wid, user.id, to_address, amountNum, 'pending_email', 'awaiting_email_confirm');
181
+ issueCode(user.id, 'email', user.email, 'withdraw_confirm:' + wid);
182
+ return void res.json({
183
+ success: true,
184
+ request_id: wid,
185
+ requires_email_code: true,
186
+ email: maskEmail(user.email),
187
+ message: '已发送邮件验证码,请查收并输入 6 位数字以确认提现',
188
+ });
189
+ }
190
+ // 普通额度 → 即时扣款 + pending(admin 处理)
191
+ const wid = generateId('wdr');
192
+ db.prepare(`INSERT INTO withdrawal_requests (id, user_id, to_address, amount) VALUES (?,?,?,?)`)
193
+ .run(wid, user.id, to_address, amountNum);
194
+ db.prepare('UPDATE wallets SET balance = balance - ? WHERE user_id = ?').run(amountNum, user.id);
195
+ res.json({
196
+ success: true,
197
+ request_id: wid,
198
+ message: '提现申请已提交,将在 24 小时内到账。',
199
+ });
200
+ });
201
+ // 大额提现:邮件验证码确认
202
+ app.post('/api/wallet/withdraw/:id/confirm', (req, res) => {
203
+ const user = auth(req, res);
204
+ if (!user)
205
+ return;
206
+ const wid = req.params.id;
207
+ const code = String(req.body?.code || '').trim();
208
+ if (!/^\d{6}$/.test(code))
209
+ return void res.json({ error: '请输入 6 位验证码' });
210
+ const wr = db.prepare(`
211
+ SELECT id, user_id, amount, to_address, status FROM withdrawal_requests WHERE id = ?
212
+ `).get(wid);
213
+ if (!wr || wr.user_id !== user.id)
214
+ return void res.json({ error: '请求不存在' });
215
+ if (wr.status !== 'pending_email')
216
+ return void res.json({ error: '该请求无需邮件确认或已确认' });
217
+ const purpose = 'withdraw_confirm:' + wid;
218
+ const row = findActiveCode('email', user.email, purpose);
219
+ if (!row)
220
+ return void res.json({ error: '验证码已过期,请重新发起提现' });
221
+ if (row.code !== code)
222
+ return void res.json({ error: '验证码错误' });
223
+ db.prepare("UPDATE verification_codes SET used_at = datetime('now') WHERE id = ?").run(row.id);
224
+ const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
225
+ if (wallet.balance < wr.amount) {
226
+ db.prepare("UPDATE withdrawal_requests SET status = 'rejected', status_detail = 'insufficient_balance_at_confirm' WHERE id = ?").run(wid);
227
+ return void res.json({ error: '余额不足(确认时刻余额已变化),请重新提现' });
228
+ }
229
+ db.prepare('UPDATE wallets SET balance = balance - ? WHERE user_id = ?').run(wr.amount, user.id);
230
+ db.prepare(`UPDATE withdrawal_requests SET status = 'pending', email_confirmed_at = datetime('now'), status_detail = NULL WHERE id = ?`).run(wid);
231
+ res.json({ success: true, message: '邮件确认通过,提现进入处理队列,24 小时内到账' });
232
+ });
233
+ // 用户取消尚未 approve 的 withdrawal — 余额自动退回
234
+ app.post('/api/wallet/withdrawals/:id/cancel', (req, res) => {
235
+ const user = auth(req, res);
236
+ if (!user)
237
+ return;
238
+ const wid = req.params.id;
239
+ const tx = db.transaction(() => {
240
+ // SELECT inside tx + UPDATE WHERE status='pending' 双重门防并发取消 + admin approve 抢跑
241
+ const wr = db.prepare(`SELECT user_id, amount, status FROM withdrawal_requests WHERE id = ?`).get(wid);
242
+ if (!wr)
243
+ throw new Error('withdrawal_not_found');
244
+ if (wr.user_id !== user.id)
245
+ throw new Error('not_owner');
246
+ if (wr.status !== 'pending' && wr.status !== 'pending_email')
247
+ throw new Error('cannot_cancel_in_status_' + wr.status);
248
+ const isPendingEmail = wr.status === 'pending_email';
249
+ // pending 阶段已扣款 → 退;pending_email 还没扣款 → 不退
250
+ const upd = db.prepare(`UPDATE withdrawal_requests SET status = 'cancelled', status_detail = 'user_cancelled' WHERE id = ? AND status = ?`)
251
+ .run(wid, wr.status);
252
+ if (upd.changes === 0)
253
+ throw new Error('race_status_changed');
254
+ if (!isPendingEmail) {
255
+ db.prepare(`UPDATE wallets SET balance = balance + ? WHERE user_id = ?`).run(wr.amount, user.id);
256
+ return wr.amount;
257
+ }
258
+ return 0;
259
+ });
260
+ try {
261
+ const refunded = tx();
262
+ res.json({ success: true, refunded });
263
+ }
264
+ catch (e) {
265
+ const msg = e.message;
266
+ const status = msg === 'not_owner' || msg.startsWith('cannot_cancel_in_status_') ? 403
267
+ : msg === 'withdrawal_not_found' ? 404
268
+ : msg === 'race_status_changed' ? 409
269
+ : 400;
270
+ res.status(status).json({ error: msg });
271
+ }
272
+ });
273
+ }
@@ -0,0 +1,188 @@
1
+ import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server';
2
+ import { randomBytes } from 'node:crypto';
3
+ export function registerWebauthnRoutes(app, deps) {
4
+ const { db, auth, generateId, rpId, rpName, origin, challengeTtlMs, gateTtlMs, invalidateAgentRiskCacheForUser, requireHumanPresence } = deps;
5
+ // 1. 注册:start — 生成 challenge + 选项
6
+ app.post('/api/webauthn/register/start', async (req, res) => {
7
+ const user = auth(req, res);
8
+ if (!user)
9
+ return;
10
+ const existing = db.prepare('SELECT id FROM webauthn_credentials WHERE user_id = ?').all(user.id);
11
+ const opts = await generateRegistrationOptions({
12
+ rpName,
13
+ rpID: rpId,
14
+ userName: String(user.handle || user.name || user.id),
15
+ userID: new TextEncoder().encode(String(user.id)),
16
+ attestationType: 'none',
17
+ excludeCredentials: existing.map(c => ({ id: c.id })),
18
+ // H-1: 注册时就强制生物识别 / PIN,否则只按硬件键 = 无 UV 闸门
19
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
20
+ });
21
+ const chId = generateId('wac');
22
+ db.prepare(`INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, expires_at) VALUES (?,?,?,?,?)`)
23
+ .run(chId, user.id, opts.challenge, 'register', new Date(Date.now() + challengeTtlMs).toISOString());
24
+ res.json({ options: opts, challenge_id: chId });
25
+ });
26
+ // 2. 注册:finish — 验证 + 入库
27
+ app.post('/api/webauthn/register/finish', async (req, res) => {
28
+ const user = auth(req, res);
29
+ if (!user)
30
+ return;
31
+ const { challenge_id, response, device_label } = req.body || {};
32
+ const ch = db.prepare(`SELECT challenge, expires_at, consumed_at FROM webauthn_challenges WHERE id = ? AND user_id = ? AND purpose = 'register'`).get(challenge_id, user.id);
33
+ if (!ch)
34
+ return void res.status(404).json({ error: 'challenge not found' });
35
+ if (ch.consumed_at)
36
+ return void res.status(409).json({ error: 'challenge already used' });
37
+ if (new Date(ch.expires_at).getTime() < Date.now())
38
+ return void res.status(410).json({ error: 'challenge expired' });
39
+ try {
40
+ const verification = await verifyRegistrationResponse({
41
+ response,
42
+ expectedChallenge: ch.challenge,
43
+ expectedOrigin: origin,
44
+ expectedRPID: rpId,
45
+ // H-1: 大额闸门必须有真正的生物识别 / PIN(user verified bit = 1)
46
+ requireUserVerification: true,
47
+ });
48
+ if (!verification.verified || !verification.registrationInfo) {
49
+ return void res.status(400).json({ error: 'verification failed' });
50
+ }
51
+ const { credential } = verification.registrationInfo;
52
+ db.prepare(`INSERT INTO webauthn_credentials (id, user_id, public_key, counter, transports, device_label) VALUES (?,?,?,?,?,?)`)
53
+ .run(credential.id, user.id, Buffer.from(credential.publicKey), credential.counter || 0, JSON.stringify(credential.transports || []), (device_label || '').slice(0, 60) || null);
54
+ db.prepare("UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE id = ?").run(challenge_id);
55
+ invalidateAgentRiskCacheForUser(user.id); // 让 D2b 中间件立刻看到刚绑的 Passkey,否则 5min 缓存窗内仍被拦
56
+ res.json({ success: true, credential_id: credential.id });
57
+ }
58
+ catch (e) {
59
+ res.status(400).json({ error: e.message });
60
+ }
61
+ });
62
+ // 3. 认证:start — 生成 challenge(指定 purpose + 业务数据;同一 challenge 不可复用)
63
+ app.post('/api/webauthn/auth/start', async (req, res) => {
64
+ const user = auth(req, res);
65
+ if (!user)
66
+ return;
67
+ const purpose = String(req.body?.purpose || '').trim();
68
+ const allowed = new Set(['withdraw', 'change-password', 'reveal-key', 'region', 'delete_passkey']);
69
+ if (!allowed.has(purpose))
70
+ return void res.status(400).json({ error: 'invalid purpose' });
71
+ const purpose_data = req.body?.purpose_data ?? null;
72
+ const creds = db.prepare('SELECT id, transports FROM webauthn_credentials WHERE user_id = ?').all(user.id);
73
+ if (creds.length === 0)
74
+ return void res.status(403).json({ error: '尚未注册任何 Passkey' });
75
+ const opts = await generateAuthenticationOptions({
76
+ rpID: rpId,
77
+ // H-1: 闸门必须真正 UV,不接受 "硬件按一下" 兜底
78
+ userVerification: 'required',
79
+ allowCredentials: creds.map(c => ({ id: c.id, transports: (() => { try {
80
+ return JSON.parse(c.transports);
81
+ }
82
+ catch {
83
+ return [];
84
+ } })() })),
85
+ });
86
+ const chId = generateId('wac');
87
+ db.prepare(`INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, purpose_data, expires_at) VALUES (?,?,?,?,?,?)`)
88
+ .run(chId, user.id, opts.challenge, purpose, purpose_data ? JSON.stringify(purpose_data) : null, new Date(Date.now() + challengeTtlMs).toISOString());
89
+ res.json({ options: opts, challenge_id: chId });
90
+ });
91
+ // 4. 认证:finish — 验证签名 + 颁发短 gate token
92
+ app.post('/api/webauthn/auth/finish', async (req, res) => {
93
+ const user = auth(req, res);
94
+ if (!user)
95
+ return;
96
+ const { challenge_id, response } = req.body || {};
97
+ const ch = db.prepare(`SELECT challenge, purpose, purpose_data, expires_at, consumed_at FROM webauthn_challenges WHERE id = ? AND user_id = ?`).get(challenge_id, user.id);
98
+ if (!ch)
99
+ return void res.status(404).json({ error: 'challenge not found' });
100
+ if (ch.consumed_at)
101
+ return void res.status(409).json({ error: 'challenge already used' });
102
+ if (new Date(ch.expires_at).getTime() < Date.now())
103
+ return void res.status(410).json({ error: 'challenge expired' });
104
+ const cred = db.prepare(`SELECT id, public_key, counter, transports FROM webauthn_credentials WHERE id = ? AND user_id = ?`)
105
+ .get(response?.id, user.id);
106
+ if (!cred)
107
+ return void res.status(404).json({ error: 'credential not registered' });
108
+ try {
109
+ const verification = await verifyAuthenticationResponse({
110
+ response,
111
+ expectedChallenge: ch.challenge,
112
+ expectedOrigin: origin,
113
+ expectedRPID: rpId,
114
+ credential: {
115
+ id: cred.id,
116
+ publicKey: new Uint8Array(cred.public_key),
117
+ counter: cred.counter,
118
+ transports: (() => { try {
119
+ return JSON.parse(cred.transports);
120
+ }
121
+ catch {
122
+ return undefined;
123
+ } })(),
124
+ },
125
+ // H-1: 签名必须由 UV 通过的 authenticator 产生(user verified bit = 1)
126
+ requireUserVerification: true,
127
+ });
128
+ if (!verification.verified)
129
+ return void res.status(400).json({ error: 'signature failed' });
130
+ // 更新 counter(防重放)
131
+ db.prepare(`UPDATE webauthn_credentials SET counter = ?, last_used_at = datetime('now') WHERE id = ?`)
132
+ .run(verification.authenticationInfo.newCounter, cred.id);
133
+ db.prepare("UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE id = ?").run(challenge_id);
134
+ // 颁发短 token
135
+ const token = generateId('wgt') + '_' + randomBytes(8).toString('hex');
136
+ db.prepare(`INSERT INTO webauthn_gate_tokens (id, user_id, purpose, purpose_data, expires_at) VALUES (?,?,?,?,?)`)
137
+ .run(token, user.id, ch.purpose, ch.purpose_data, new Date(Date.now() + gateTtlMs).toISOString());
138
+ res.json({ success: true, gate_token: token, expires_in_seconds: Math.floor(gateTtlMs / 1000) });
139
+ }
140
+ catch (e) {
141
+ res.status(400).json({ error: e.message });
142
+ }
143
+ });
144
+ // 列出 / 删除 credential
145
+ app.get('/api/webauthn/credentials', (req, res) => {
146
+ const user = auth(req, res);
147
+ if (!user)
148
+ return;
149
+ const rows = db.prepare(`SELECT id, device_label, transports, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC`).all(user.id);
150
+ const required = !!user.webauthn_required_for_withdraw;
151
+ res.json({ credentials: rows, settings: { required_for_withdraw: required } });
152
+ });
153
+ app.delete('/api/webauthn/credentials/:id', (req, res) => {
154
+ const user = auth(req, res);
155
+ if (!user)
156
+ return;
157
+ // #1044 防"失窃 Passkey 不需 Passkey 即可删除"漏洞 — 删 passkey 自身要先用同一把(或同账号其它)passkey ceremony 拿 token
158
+ // 验证 purpose_data.credential_id 必须等于路径 :id,避免"为删 A 拿到的 token 被复用去删 B"
159
+ const hpCheck = requireHumanPresence(user.id, 'delete_passkey', (req.body || {}).webauthn_token, 'require_human_presence_for_delete_passkey', (data) => {
160
+ try {
161
+ return typeof data === 'object' && data !== null && data.credential_id === req.params.id;
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ });
167
+ if (!hpCheck.ok)
168
+ return void res.status(412).json({ error: hpCheck.reason, error_code: hpCheck.error_code });
169
+ const r = db.prepare('DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?').run(req.params.id, user.id);
170
+ if (r.changes > 0)
171
+ invalidateAgentRiskCacheForUser(user.id); // 删到最后一把就丢真人身份,立刻反映到 D2b
172
+ res.json({ success: true, deleted: r.changes });
173
+ });
174
+ app.post('/api/webauthn/settings', (req, res) => {
175
+ const user = auth(req, res);
176
+ if (!user)
177
+ return;
178
+ const required = req.body?.required_for_withdraw ? 1 : 0;
179
+ // 开启前必须至少有 1 个 credential
180
+ if (required) {
181
+ const n = db.prepare('SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?').get(user.id).n;
182
+ if (n === 0)
183
+ return void res.status(400).json({ error: '请先注册至少一个 Passkey' });
184
+ }
185
+ db.prepare('UPDATE users SET webauthn_required_for_withdraw = ? WHERE id = ?').run(required, user.id);
186
+ res.json({ success: true, required_for_withdraw: !!required });
187
+ });
188
+ }
@@ -0,0 +1,162 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { isPrivateOrInternalHost } from '../security/ssrf.js';
3
+ export const WEBHOOK_EVENT_TYPES = [
4
+ 'order.created', 'order.paid', 'order.shipped', 'order.delivered', 'order.completed', 'order.disputed',
5
+ 'wish.claimed', 'wish.proof', 'wish.confirmed', 'wish.repay', 'wish.repay_resp',
6
+ 'rfq.bid_received', 'rfq.awarded',
7
+ 'charity.donation', 'charity.fund_redirect',
8
+ ];
9
+ // P2.2 失败通知:连续 5 次失败 → 自动 active=0 + 通知用户
10
+ function recordWebhookFailure(db, generateId, sub, errMsg) {
11
+ db.prepare(`UPDATE webhook_subscriptions SET fail_count = fail_count + 1, last_error = ?, last_fired_at = datetime('now') WHERE id = ?`).run(errMsg, sub.id);
12
+ const after = db.prepare(`SELECT fail_count, active FROM webhook_subscriptions WHERE id = ?`).get(sub.id);
13
+ if (after.active && after.fail_count > 0 && after.fail_count % 5 === 0) {
14
+ try {
15
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
16
+ VALUES (?,?,'webhook_fail',?,?,datetime('now'))`)
17
+ .run(generateId('ntf'), sub.user_id, `⚠ Webhook 连续 ${after.fail_count} 次失败`, `${sub.event_type} → ${String(sub.target_url).slice(0, 60)}... · ${errMsg}`);
18
+ }
19
+ catch (e) {
20
+ console.error('[webhook notify fail]', e);
21
+ }
22
+ // 失败 >= 20 次 → 自动暂停
23
+ if (after.fail_count >= 20) {
24
+ db.prepare(`UPDATE webhook_subscriptions SET active = 0 WHERE id = ?`).run(sub.id);
25
+ }
26
+ }
27
+ }
28
+ // 触发 webhook 投递(v1 同步 fetch,超时 5s)
29
+ // 跨域 API — charity / RFQ / orders 等通过此函数广播事件
30
+ export async function fireWebhooks(db, generateId, eventType, payload, userIds) {
31
+ const where = userIds && userIds.length
32
+ ? `event_type = ? AND active = 1 AND user_id IN (${userIds.map(() => '?').join(',')})`
33
+ : `event_type = ? AND active = 1`;
34
+ const args = [eventType, ...(userIds || [])];
35
+ const subs = db.prepare(`SELECT * FROM webhook_subscriptions WHERE ${where}`).all(...args);
36
+ for (const sub of subs) {
37
+ // P1.1 SSRF:投递前再次校验(防止旧订阅或 DB 直改绕过创建时检查)
38
+ if (isPrivateOrInternalHost(String(sub.target_url))) {
39
+ db.prepare(`UPDATE webhook_subscriptions SET fail_count = fail_count + 1, last_error = ?, active = 0 WHERE id = ?`)
40
+ .run('blocked: private/internal host', sub.id);
41
+ continue;
42
+ }
43
+ const body = JSON.stringify({ event: eventType, payload, ts: new Date().toISOString() });
44
+ const sig = sub.secret ? createHmac('sha256', String(sub.secret)).update(body).digest('hex') : null;
45
+ try {
46
+ const ctrl = new AbortController();
47
+ const tm = setTimeout(() => ctrl.abort(), 5000);
48
+ const r = await fetch(String(sub.target_url), {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json', ...(sig ? { 'X-WebAZ-Signature': sig } : {}) },
51
+ body, signal: ctrl.signal,
52
+ });
53
+ clearTimeout(tm);
54
+ if (r.ok) {
55
+ db.prepare(`UPDATE webhook_subscriptions SET fire_count = fire_count + 1, last_fired_at = datetime('now'), last_error = NULL WHERE id = ?`).run(sub.id);
56
+ }
57
+ else {
58
+ recordWebhookFailure(db, generateId, sub, 'HTTP ' + r.status);
59
+ }
60
+ }
61
+ catch (e) {
62
+ recordWebhookFailure(db, generateId, sub, String(e.message).slice(0, 200));
63
+ }
64
+ }
65
+ }
66
+ export function registerWebhookRoutes(app, deps) {
67
+ const { db, auth, generateId, rateLimitOk } = deps;
68
+ // POST 订阅
69
+ app.post('/api/webhooks', (req, res) => {
70
+ const user = auth(req, res);
71
+ if (!user)
72
+ return;
73
+ if (!rateLimitOk(req.ip || '', 10, 60_000))
74
+ return void res.status(429).json({ error: '请求过于频繁' });
75
+ const body = req.body;
76
+ const url = String(body.target_url || '').trim();
77
+ const eventType = String(body.event_type || '').trim();
78
+ const secret = body.secret ? String(body.secret).slice(0, 200) : null;
79
+ if (!url.startsWith('https://'))
80
+ return void res.json({ error: 'target_url 必须以 https:// 开头' });
81
+ if (url.length > 500)
82
+ return void res.json({ error: 'URL 过长' });
83
+ // P1.1 SSRF 修复:拒绝私网/localhost/metadata 端点
84
+ if (isPrivateOrInternalHost(url))
85
+ return void res.json({ error: 'target_url 不可指向私网/localhost/内部地址' });
86
+ if (!WEBHOOK_EVENT_TYPES.includes(eventType))
87
+ return void res.json({ error: '不支持的 event_type' });
88
+ // 每用户最多 20 个订阅
89
+ const cnt = db.prepare(`SELECT COUNT(1) as n FROM webhook_subscriptions WHERE user_id = ?`).get(user.id).n;
90
+ if (cnt >= 20)
91
+ return void res.json({ error: '订阅数量上限 20' });
92
+ const id = generateId('whk');
93
+ db.prepare(`INSERT INTO webhook_subscriptions (id, user_id, event_type, target_url, secret) VALUES (?,?,?,?,?)`)
94
+ .run(id, user.id, eventType, url, secret);
95
+ res.json({ id, event_type: eventType, target_url: url });
96
+ });
97
+ // GET 我的订阅
98
+ app.get('/api/webhooks', (req, res) => {
99
+ const user = auth(req, res);
100
+ if (!user)
101
+ return;
102
+ const items = db.prepare(`SELECT id, event_type, target_url, active, last_fired_at, fire_count, fail_count, last_error, created_at
103
+ FROM webhook_subscriptions WHERE user_id = ? ORDER BY created_at DESC`).all(user.id);
104
+ res.json({ items, event_types: WEBHOOK_EVENT_TYPES });
105
+ });
106
+ // DELETE
107
+ app.delete('/api/webhooks/:id', (req, res) => {
108
+ const user = auth(req, res);
109
+ if (!user)
110
+ return;
111
+ const r = db.prepare(`DELETE FROM webhook_subscriptions WHERE id = ? AND user_id = ?`).run(req.params.id, user.id);
112
+ if (r.changes === 0)
113
+ return void res.json({ error: '订阅不存在或非你所有' });
114
+ res.json({ ok: true });
115
+ });
116
+ // PATCH active toggle
117
+ app.patch('/api/webhooks/:id', (req, res) => {
118
+ const user = auth(req, res);
119
+ if (!user)
120
+ return;
121
+ const active = req.body.active ? 1 : 0;
122
+ const r = db.prepare(`UPDATE webhook_subscriptions SET active = ? WHERE id = ? AND user_id = ?`).run(active, req.params.id, user.id);
123
+ if (r.changes === 0)
124
+ return void res.json({ error: '订阅不存在' });
125
+ res.json({ ok: true, active });
126
+ });
127
+ // P2.4 测试端点:subscribe 前先验 endpoint 可达 + 不私网
128
+ app.post('/api/webhooks/test', async (req, res) => {
129
+ const user = auth(req, res);
130
+ if (!user)
131
+ return;
132
+ if (!rateLimitOk(req.ip || '', 5, 60_000))
133
+ return void res.status(429).json({ error: '请求过于频繁' });
134
+ const body = req.body;
135
+ const url = String(body.target_url || '').trim();
136
+ const secret = body.secret ? String(body.secret).slice(0, 200) : null;
137
+ if (!url.startsWith('https://'))
138
+ return void res.json({ ok: false, error: 'target_url 必须 https://' });
139
+ if (isPrivateOrInternalHost(url))
140
+ return void res.json({ ok: false, error: 'target_url 不可指向私网/localhost/内部地址' });
141
+ const payload = JSON.stringify({ event: 'webaz.test_ping', payload: { hello: 'from WebAZ', user_id: user.id }, ts: new Date().toISOString() });
142
+ const sig = secret ? createHmac('sha256', secret).update(payload).digest('hex') : null;
143
+ try {
144
+ const ctrl = new AbortController();
145
+ const tm = setTimeout(() => ctrl.abort(), 5000);
146
+ const t0 = Date.now();
147
+ const r = await fetch(url, {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json', ...(sig ? { 'X-WebAZ-Signature': sig } : {}) },
150
+ body: payload, signal: ctrl.signal,
151
+ });
152
+ clearTimeout(tm);
153
+ const ms = Date.now() - t0;
154
+ if (r.ok)
155
+ return void res.json({ ok: true, status: r.status, ms });
156
+ return void res.json({ ok: false, status: r.status, ms, error: 'HTTP ' + r.status });
157
+ }
158
+ catch (e) {
159
+ return void res.json({ ok: false, error: String(e.message).slice(0, 200) });
160
+ }
161
+ });
162
+ }