@seasonkoh/webaz 0.1.23 → 0.1.25

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 (187) hide show
  1. package/README.md +2 -0
  2. package/dist/layer0-foundation/L0-1-database/db-backends/pg-backend.js +51 -0
  3. package/dist/layer0-foundation/L0-1-database/db-backends/sql-dialect-datetime.js +437 -0
  4. package/dist/layer0-foundation/L0-1-database/db-backends/sql-placeholders.js +98 -0
  5. package/dist/layer0-foundation/L0-1-database/db.js +65 -0
  6. package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +13 -11
  7. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +1 -1
  8. package/dist/layer0-foundation/L0-5-manifest/manifest.js +13 -11
  9. package/dist/layer1-agent/L1-1-mcp-server/server.js +198 -83
  10. package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +14 -12
  11. package/dist/layer2-business/L2-6-notifications/notification-engine.js +8 -5
  12. package/dist/layer2-business/L2-7-snf/snf-engine.js +16 -14
  13. package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +18 -10
  14. package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +37 -23
  15. package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +173 -0
  16. package/dist/layer2-business/L2-9-contribution/build-task-participation.js +47 -0
  17. package/dist/layer2-business/L2-9-contribution/build-task-read.js +222 -0
  18. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +10 -2
  19. package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +16 -0
  20. package/dist/layer2-business/L2-9-contribution/contribution-display-envelope.js +40 -0
  21. package/dist/layer2-business/L2-9-contribution/contribution-score-contract.js +36 -0
  22. package/dist/layer2-business/L2-9-contribution/contribution-score-evidence.js +61 -0
  23. package/dist/layer2-business/L2-9-contribution/github-credential/canonical.js +60 -0
  24. package/dist/layer2-business/L2-9-contribution/github-credential/github-credential.schema.js +140 -0
  25. package/dist/layer2-business/L2-9-contribution/github-credential/github-fetch-adapter.js +437 -0
  26. package/dist/layer2-business/L2-9-contribution/github-credential/self-consistency.js +38 -0
  27. package/dist/layer2-business/L2-9-contribution/github-credential/verifier.js +231 -0
  28. package/dist/layer2-business/L2-9-contribution/github-credential-ingestion-engine.js +145 -0
  29. package/dist/layer2-business/L2-9-contribution/github-credential-store.js +115 -0
  30. package/dist/layer2-business/L2-9-contribution/identity-binding-engine.js +134 -0
  31. package/dist/layer2-business/L2-9-contribution/identity-binding-store.js +101 -0
  32. package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-engine.js +126 -0
  33. package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-store.js +30 -0
  34. package/dist/layer2-business/L2-9-contribution/identity-claim-engine.js +109 -0
  35. package/dist/layer2-business/L2-9-contribution/identity-claim-fact-precondition.js +22 -0
  36. package/dist/layer2-business/L2-9-contribution/identity-claim-proof-verifier.js +97 -0
  37. package/dist/layer2-business/L2-9-contribution/identity-claim-read.js +59 -0
  38. package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +129 -0
  39. package/dist/layer2-business/L2-notes/note-photo-storage.js +4 -2
  40. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +17 -15
  41. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +11 -8
  42. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +9 -8
  43. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +11 -8
  44. package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +22 -16
  45. package/dist/pwa/acp-feed.js +13 -1
  46. package/dist/pwa/contract-fingerprint.js +2 -0
  47. package/dist/pwa/endpoint-actions.js +5 -1
  48. package/dist/pwa/goal-index.js +8 -8
  49. package/dist/pwa/human-presence.js +62 -0
  50. package/dist/pwa/public/app.js +575 -68
  51. package/dist/pwa/public/i18n.js +29 -20
  52. package/dist/pwa/public/index.html +1 -0
  53. package/dist/pwa/public/openapi.json +2 -2
  54. package/dist/pwa/rate-limit.js +22 -0
  55. package/dist/pwa/routes/account-deletion.js +15 -13
  56. package/dist/pwa/routes/addresses.js +10 -9
  57. package/dist/pwa/routes/admin-admins.js +13 -14
  58. package/dist/pwa/routes/admin-analytics.js +109 -69
  59. package/dist/pwa/routes/admin-catalog.js +13 -11
  60. package/dist/pwa/routes/admin-editor-picks.js +15 -10
  61. package/dist/pwa/routes/admin-events.js +5 -3
  62. package/dist/pwa/routes/admin-health.js +2 -1
  63. package/dist/pwa/routes/admin-moderation.js +26 -29
  64. package/dist/pwa/routes/admin-ops.js +22 -21
  65. package/dist/pwa/routes/admin-protocol-params.js +16 -19
  66. package/dist/pwa/routes/admin-reports.js +23 -21
  67. package/dist/pwa/routes/admin-tokenomics.js +26 -25
  68. package/dist/pwa/routes/admin-users-lifecycle.js +37 -40
  69. package/dist/pwa/routes/admin-users-query.js +54 -53
  70. package/dist/pwa/routes/admin-verifier-flow.js +82 -41
  71. package/dist/pwa/routes/admin-verifier-whitelist.js +55 -27
  72. package/dist/pwa/routes/admin-wallet-ops.js +7 -5
  73. package/dist/pwa/routes/agent-buy.js +46 -22
  74. package/dist/pwa/routes/agent-governance.js +52 -56
  75. package/dist/pwa/routes/ai.js +7 -5
  76. package/dist/pwa/routes/analytics.js +43 -41
  77. package/dist/pwa/routes/anchors.js +19 -20
  78. package/dist/pwa/routes/announcements.js +13 -13
  79. package/dist/pwa/routes/arbitrator.js +97 -31
  80. package/dist/pwa/routes/auction.js +153 -114
  81. package/dist/pwa/routes/auth-login.js +6 -4
  82. package/dist/pwa/routes/auth-read.js +11 -9
  83. package/dist/pwa/routes/auth-register.js +35 -20
  84. package/dist/pwa/routes/auth-sessions.js +12 -11
  85. package/dist/pwa/routes/blocklist.js +16 -15
  86. package/dist/pwa/routes/build-feedback.js +10 -9
  87. package/dist/pwa/routes/build-reputation.js +6 -2
  88. package/dist/pwa/routes/build-tasks.js +45 -13
  89. package/dist/pwa/routes/buyer-feeds.js +27 -25
  90. package/dist/pwa/routes/cart.js +16 -15
  91. package/dist/pwa/routes/charity.js +212 -150
  92. package/dist/pwa/routes/chat.js +42 -43
  93. package/dist/pwa/routes/checkin-tasks.js +10 -9
  94. package/dist/pwa/routes/checkout-helpers.js +12 -10
  95. package/dist/pwa/routes/claim-initiators.js +34 -14
  96. package/dist/pwa/routes/claim-verify.js +86 -53
  97. package/dist/pwa/routes/claim-voting.js +43 -18
  98. package/dist/pwa/routes/contribution-identity.js +147 -0
  99. package/dist/pwa/routes/contribution-score.js +19 -0
  100. package/dist/pwa/routes/coupons.js +19 -16
  101. package/dist/pwa/routes/dashboards.js +18 -16
  102. package/dist/pwa/routes/dispute-cases.js +25 -24
  103. package/dist/pwa/routes/disputes-read.js +45 -51
  104. package/dist/pwa/routes/disputes-write.js +124 -61
  105. package/dist/pwa/routes/evidence.js +9 -9
  106. package/dist/pwa/routes/external-anchors.js +13 -12
  107. package/dist/pwa/routes/feedback.js +29 -33
  108. package/dist/pwa/routes/flash-sales.js +18 -16
  109. package/dist/pwa/routes/follows.js +25 -24
  110. package/dist/pwa/routes/governance-auto-deactivate.js +21 -9
  111. package/dist/pwa/routes/governance-onboarding.js +70 -59
  112. package/dist/pwa/routes/group-buys.js +22 -22
  113. package/dist/pwa/routes/growth.js +33 -30
  114. package/dist/pwa/routes/import-product.js +12 -10
  115. package/dist/pwa/routes/kyc.js +9 -8
  116. package/dist/pwa/routes/leaderboard.js +20 -18
  117. package/dist/pwa/routes/listings.js +23 -22
  118. package/dist/pwa/routes/logistics.js +10 -8
  119. package/dist/pwa/routes/manifests.js +27 -27
  120. package/dist/pwa/routes/me-data.js +23 -21
  121. package/dist/pwa/routes/notifications.js +7 -6
  122. package/dist/pwa/routes/offers.js +30 -12
  123. package/dist/pwa/routes/orders-action.js +33 -17
  124. package/dist/pwa/routes/orders-create.js +75 -20
  125. package/dist/pwa/routes/orders-read.js +21 -20
  126. package/dist/pwa/routes/p2p-products.js +30 -18
  127. package/dist/pwa/routes/payments-governance.js +61 -56
  128. package/dist/pwa/routes/peers.js +9 -8
  129. package/dist/pwa/routes/pin-receipts.js +13 -13
  130. package/dist/pwa/routes/products-aliases.js +12 -10
  131. package/dist/pwa/routes/products-claims.js +36 -17
  132. package/dist/pwa/routes/products-create.js +53 -38
  133. package/dist/pwa/routes/products-crud.js +17 -16
  134. package/dist/pwa/routes/products-links.js +49 -26
  135. package/dist/pwa/routes/products-list.js +6 -4
  136. package/dist/pwa/routes/products-meta.js +40 -39
  137. package/dist/pwa/routes/products-update.js +19 -5
  138. package/dist/pwa/routes/profile-credentials.js +14 -16
  139. package/dist/pwa/routes/profile-identity.js +14 -13
  140. package/dist/pwa/routes/profile-location.js +7 -6
  141. package/dist/pwa/routes/profile-placement.js +19 -17
  142. package/dist/pwa/routes/profile-prefs.js +11 -11
  143. package/dist/pwa/routes/promoter.js +55 -49
  144. package/dist/pwa/routes/public-build-tasks.js +19 -0
  145. package/dist/pwa/routes/public-utils.js +108 -46
  146. package/dist/pwa/routes/push.js +16 -15
  147. package/dist/pwa/routes/ratings.js +30 -30
  148. package/dist/pwa/routes/recover-key.js +13 -12
  149. package/dist/pwa/routes/referral.js +37 -32
  150. package/dist/pwa/routes/reputation.js +3 -2
  151. package/dist/pwa/routes/returns.js +76 -73
  152. package/dist/pwa/routes/reviews.js +41 -18
  153. package/dist/pwa/routes/rewards-apply.js +16 -15
  154. package/dist/pwa/routes/rewards-auto-downgrade.js +9 -7
  155. package/dist/pwa/routes/rewards-escrow-expire.js +7 -5
  156. package/dist/pwa/routes/rfqs.js +163 -85
  157. package/dist/pwa/routes/search.js +16 -14
  158. package/dist/pwa/routes/secondhand.js +25 -22
  159. package/dist/pwa/routes/seller-quota.js +24 -26
  160. package/dist/pwa/routes/share-redirects.js +59 -55
  161. package/dist/pwa/routes/shareables-interactions.js +34 -35
  162. package/dist/pwa/routes/shareables.js +55 -51
  163. package/dist/pwa/routes/shop-referral.js +57 -0
  164. package/dist/pwa/routes/shops.js +20 -18
  165. package/dist/pwa/routes/signaling.js +10 -9
  166. package/dist/pwa/routes/skill-market.js +16 -16
  167. package/dist/pwa/routes/skills.js +15 -14
  168. package/dist/pwa/routes/snf.js +14 -13
  169. package/dist/pwa/routes/tags.js +10 -9
  170. package/dist/pwa/routes/task-proposals.js +45 -0
  171. package/dist/pwa/routes/trial.js +69 -51
  172. package/dist/pwa/routes/trusted-kpi.js +20 -18
  173. package/dist/pwa/routes/url-claim.js +67 -28
  174. package/dist/pwa/routes/users-public.js +62 -60
  175. package/dist/pwa/routes/variants.js +12 -13
  176. package/dist/pwa/routes/verifier-user.js +61 -21
  177. package/dist/pwa/routes/verify-tasks.js +49 -25
  178. package/dist/pwa/routes/waitlist.js +16 -15
  179. package/dist/pwa/routes/wallet-read.js +74 -36
  180. package/dist/pwa/routes/wallet-write.js +12 -9
  181. package/dist/pwa/routes/webauthn.js +25 -26
  182. package/dist/pwa/routes/webhooks.js +26 -26
  183. package/dist/pwa/routes/welcome.js +45 -50
  184. package/dist/pwa/routes/wishlist-qa.js +29 -32
  185. package/dist/pwa/server.js +237 -81
  186. package/dist/version.js +1 -1
  187. package/package.json +47 -2
@@ -2,6 +2,7 @@ import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthen
2
2
  import { randomBytes } from 'node:crypto';
3
3
  // RFC-004 体验补:绑定 Passkey 后,追溯补发此前"已受理但无锚点跳过"的建设信誉。
4
4
  import { grantPendingAnchorCredits } from '../../layer2-business/L2-8-feedback/build-feedback-engine.js';
5
+ import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
5
6
  export function registerWebauthnRoutes(app, deps) {
6
7
  const { db, auth, generateId, rpId, rpName, origin, challengeTtlMs, gateTtlMs, invalidateAgentRiskCacheForUser, requireHumanPresence } = deps;
7
8
  // 1. 注册:start — 生成 challenge + 选项
@@ -9,7 +10,7 @@ export function registerWebauthnRoutes(app, deps) {
9
10
  const user = auth(req, res);
10
11
  if (!user)
11
12
  return;
12
- const existing = db.prepare('SELECT id FROM webauthn_credentials WHERE user_id = ?').all(user.id);
13
+ const existing = await dbAll('SELECT id FROM webauthn_credentials WHERE user_id = ?', [user.id]);
13
14
  const opts = await generateRegistrationOptions({
14
15
  rpName,
15
16
  rpID: rpId,
@@ -21,8 +22,7 @@ export function registerWebauthnRoutes(app, deps) {
21
22
  authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
22
23
  });
23
24
  const chId = generateId('wac');
24
- db.prepare(`INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, expires_at) VALUES (?,?,?,?,?)`)
25
- .run(chId, user.id, opts.challenge, 'register', new Date(Date.now() + challengeTtlMs).toISOString());
25
+ await dbRun(`INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, expires_at) VALUES (?,?,?,?,?)`, [chId, user.id, opts.challenge, 'register', new Date(Date.now() + challengeTtlMs).toISOString()]);
26
26
  res.json({ options: opts, challenge_id: chId });
27
27
  });
28
28
  // 2. 注册:finish — 验证 + 入库
@@ -31,7 +31,7 @@ export function registerWebauthnRoutes(app, deps) {
31
31
  if (!user)
32
32
  return;
33
33
  const { challenge_id, response, device_label } = req.body || {};
34
- 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);
34
+ const ch = await dbOne(`SELECT challenge, expires_at, consumed_at FROM webauthn_challenges WHERE id = ? AND user_id = ? AND purpose = 'register'`, [challenge_id, user.id]);
35
35
  if (!ch)
36
36
  return void res.status(404).json({ error: 'challenge not found' });
37
37
  if (ch.consumed_at)
@@ -51,9 +51,9 @@ export function registerWebauthnRoutes(app, deps) {
51
51
  return void res.status(400).json({ error: 'verification failed' });
52
52
  }
53
53
  const { credential } = verification.registrationInfo;
54
- db.prepare(`INSERT INTO webauthn_credentials (id, user_id, public_key, counter, transports, device_label) VALUES (?,?,?,?,?,?)`)
55
- .run(credential.id, user.id, Buffer.from(credential.publicKey), credential.counter || 0, JSON.stringify(credential.transports || []), (device_label || '').slice(0, 60) || null);
56
- db.prepare("UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE id = ?").run(challenge_id);
54
+ await dbRun(`INSERT INTO webauthn_credentials (id, user_id, public_key, counter, transports, device_label) VALUES (?,?,?,?,?,?)`, [credential.id, user.id, Buffer.from(credential.publicKey), credential.counter || 0,
55
+ JSON.stringify(credential.transports || []), (device_label || '').slice(0, 60) || null]);
56
+ await dbRun("UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE id = ?", [challenge_id]);
57
57
  invalidateAgentRiskCacheForUser(user.id); // 让 D2b 中间件立刻看到刚绑的 Passkey,否则 5min 缓存窗内仍被拦
58
58
  // RFC-004:绑定 Passkey = 成为可问责真人 → 追溯补发此前因无锚点跳过的建设信誉(advisory,永不阻塞绑定)
59
59
  let backfilled;
@@ -73,11 +73,11 @@ export function registerWebauthnRoutes(app, deps) {
73
73
  if (!user)
74
74
  return;
75
75
  const purpose = String(req.body?.purpose || '').trim();
76
- const allowed = new Set(['withdraw', 'change-password', 'reveal-key', 'region', 'delete_passkey', 'governance_apply', 'governance_activate', 'governance_resign', 'governance_appeal_resolve', 'rewards_apply', 'rewards_deactivate']);
76
+ const allowed = new Set(['withdraw', 'change-password', 'reveal-key', 'region', 'delete_passkey', 'governance_apply', 'governance_activate', 'governance_resign', 'governance_appeal_resolve', 'rewards_apply', 'rewards_deactivate', 'identity_claim']);
77
77
  if (!allowed.has(purpose))
78
78
  return void res.status(400).json({ error: 'invalid purpose' });
79
79
  const purpose_data = req.body?.purpose_data ?? null;
80
- const creds = db.prepare('SELECT id, transports FROM webauthn_credentials WHERE user_id = ?').all(user.id);
80
+ const creds = await dbAll('SELECT id, transports FROM webauthn_credentials WHERE user_id = ?', [user.id]);
81
81
  if (creds.length === 0)
82
82
  return void res.status(403).json({ error: '尚未注册任何 Passkey' });
83
83
  const opts = await generateAuthenticationOptions({
@@ -92,8 +92,9 @@ export function registerWebauthnRoutes(app, deps) {
92
92
  } })() })),
93
93
  });
94
94
  const chId = generateId('wac');
95
- db.prepare(`INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, purpose_data, expires_at) VALUES (?,?,?,?,?,?)`)
96
- .run(chId, user.id, opts.challenge, purpose, purpose_data ? JSON.stringify(purpose_data) : null, new Date(Date.now() + challengeTtlMs).toISOString());
95
+ await dbRun(`INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, purpose_data, expires_at) VALUES (?,?,?,?,?,?)`, [chId, user.id, opts.challenge, purpose,
96
+ purpose_data ? JSON.stringify(purpose_data) : null,
97
+ new Date(Date.now() + challengeTtlMs).toISOString()]);
97
98
  res.json({ options: opts, challenge_id: chId });
98
99
  });
99
100
  // 4. 认证:finish — 验证签名 + 颁发短 gate token
@@ -102,15 +103,14 @@ export function registerWebauthnRoutes(app, deps) {
102
103
  if (!user)
103
104
  return;
104
105
  const { challenge_id, response } = req.body || {};
105
- 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);
106
+ const ch = await dbOne(`SELECT challenge, purpose, purpose_data, expires_at, consumed_at FROM webauthn_challenges WHERE id = ? AND user_id = ?`, [challenge_id, user.id]);
106
107
  if (!ch)
107
108
  return void res.status(404).json({ error: 'challenge not found' });
108
109
  if (ch.consumed_at)
109
110
  return void res.status(409).json({ error: 'challenge already used' });
110
111
  if (new Date(ch.expires_at).getTime() < Date.now())
111
112
  return void res.status(410).json({ error: 'challenge expired' });
112
- const cred = db.prepare(`SELECT id, public_key, counter, transports FROM webauthn_credentials WHERE id = ? AND user_id = ?`)
113
- .get(response?.id, user.id);
113
+ const cred = await dbOne(`SELECT id, public_key, counter, transports FROM webauthn_credentials WHERE id = ? AND user_id = ?`, [response?.id, user.id]);
114
114
  if (!cred)
115
115
  return void res.status(404).json({ error: 'credential not registered' });
116
116
  try {
@@ -136,13 +136,12 @@ export function registerWebauthnRoutes(app, deps) {
136
136
  if (!verification.verified)
137
137
  return void res.status(400).json({ error: 'signature failed' });
138
138
  // 更新 counter(防重放)
139
- db.prepare(`UPDATE webauthn_credentials SET counter = ?, last_used_at = datetime('now') WHERE id = ?`)
140
- .run(verification.authenticationInfo.newCounter, cred.id);
141
- db.prepare("UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE id = ?").run(challenge_id);
139
+ await dbRun(`UPDATE webauthn_credentials SET counter = ?, last_used_at = datetime('now') WHERE id = ?`, [verification.authenticationInfo.newCounter, cred.id]);
140
+ await dbRun("UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE id = ?", [challenge_id]);
142
141
  // 颁发短 token
143
142
  const token = generateId('wgt') + '_' + randomBytes(8).toString('hex');
144
- db.prepare(`INSERT INTO webauthn_gate_tokens (id, user_id, purpose, purpose_data, expires_at) VALUES (?,?,?,?,?)`)
145
- .run(token, user.id, ch.purpose, ch.purpose_data, new Date(Date.now() + gateTtlMs).toISOString());
143
+ await dbRun(`INSERT INTO webauthn_gate_tokens (id, user_id, purpose, purpose_data, expires_at) VALUES (?,?,?,?,?)`, [token, user.id, ch.purpose, ch.purpose_data,
144
+ new Date(Date.now() + gateTtlMs).toISOString()]);
146
145
  res.json({ success: true, gate_token: token, expires_in_seconds: Math.floor(gateTtlMs / 1000) });
147
146
  }
148
147
  catch (e) {
@@ -150,15 +149,15 @@ export function registerWebauthnRoutes(app, deps) {
150
149
  }
151
150
  });
152
151
  // 列出 / 删除 credential
153
- app.get('/api/webauthn/credentials', (req, res) => {
152
+ app.get('/api/webauthn/credentials', async (req, res) => {
154
153
  const user = auth(req, res);
155
154
  if (!user)
156
155
  return;
157
- 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);
156
+ const rows = await dbAll(`SELECT id, device_label, transports, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC`, [user.id]);
158
157
  const required = !!user.webauthn_required_for_withdraw;
159
158
  res.json({ credentials: rows, settings: { required_for_withdraw: required } });
160
159
  });
161
- app.delete('/api/webauthn/credentials/:id', (req, res) => {
160
+ app.delete('/api/webauthn/credentials/:id', async (req, res) => {
162
161
  const user = auth(req, res);
163
162
  if (!user)
164
163
  return;
@@ -174,23 +173,23 @@ export function registerWebauthnRoutes(app, deps) {
174
173
  });
175
174
  if (!hpCheck.ok)
176
175
  return void res.status(412).json({ error: hpCheck.reason, error_code: hpCheck.error_code });
177
- const r = db.prepare('DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?').run(req.params.id, user.id);
176
+ const r = await dbRun('DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?', [req.params.id, user.id]);
178
177
  if (r.changes > 0)
179
178
  invalidateAgentRiskCacheForUser(user.id); // 删到最后一把就丢真人身份,立刻反映到 D2b
180
179
  res.json({ success: true, deleted: r.changes });
181
180
  });
182
- app.post('/api/webauthn/settings', (req, res) => {
181
+ app.post('/api/webauthn/settings', async (req, res) => {
183
182
  const user = auth(req, res);
184
183
  if (!user)
185
184
  return;
186
185
  const required = req.body?.required_for_withdraw ? 1 : 0;
187
186
  // 开启前必须至少有 1 个 credential
188
187
  if (required) {
189
- const n = db.prepare('SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?').get(user.id).n;
188
+ const n = (await dbOne('SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?', [user.id])).n;
190
189
  if (n === 0)
191
190
  return void res.status(400).json({ error: '请先注册至少一个 Passkey' });
192
191
  }
193
- db.prepare('UPDATE users SET webauthn_required_for_withdraw = ? WHERE id = ?').run(required, user.id);
192
+ await dbRun('UPDATE users SET webauthn_required_for_withdraw = ? WHERE id = ?', [required, user.id]);
194
193
  res.json({ success: true, required_for_withdraw: !!required });
195
194
  });
196
195
  }
@@ -1,5 +1,6 @@
1
1
  import { createHmac } from 'node:crypto';
2
2
  import { isPrivateOrInternalHost } from '../security/ssrf.js';
3
+ import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
3
4
  export const WEBHOOK_EVENT_TYPES = [
4
5
  'order.created', 'order.paid', 'order.shipped', 'order.delivered', 'order.completed', 'order.disputed',
5
6
  'wish.claimed', 'wish.proof', 'wish.confirmed', 'wish.repay', 'wish.repay_resp',
@@ -7,37 +8,36 @@ export const WEBHOOK_EVENT_TYPES = [
7
8
  'charity.donation', 'charity.fund_redirect',
8
9
  ];
9
10
  // 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);
11
+ // RFC-016: db 参数保留(调用方仍传),内部走异步 seam(同一实例,setSeamDb)
12
+ async function recordWebhookFailure(_db, generateId, sub, errMsg) {
13
+ await dbRun(`UPDATE webhook_subscriptions SET fail_count = fail_count + 1, last_error = ?, last_fired_at = datetime('now') WHERE id = ?`, [errMsg, sub.id]);
14
+ const after = (await dbOne(`SELECT fail_count, active FROM webhook_subscriptions WHERE id = ?`, [sub.id]));
13
15
  if (after.active && after.fail_count > 0 && after.fail_count % 5 === 0) {
14
16
  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}`);
17
+ await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
18
+ VALUES (?,?,'webhook_fail',?,?,datetime('now'))`, [generateId('ntf'), sub.user_id, `⚠ Webhook 连续 ${after.fail_count} 次失败`, `${sub.event_type} → ${String(sub.target_url).slice(0, 60)}... · ${errMsg}`]);
18
19
  }
19
20
  catch (e) {
20
21
  console.error('[webhook notify fail]', e);
21
22
  }
22
23
  // 失败 >= 20 次 → 自动暂停
23
24
  if (after.fail_count >= 20) {
24
- db.prepare(`UPDATE webhook_subscriptions SET active = 0 WHERE id = ?`).run(sub.id);
25
+ await dbRun(`UPDATE webhook_subscriptions SET active = 0 WHERE id = ?`, [sub.id]);
25
26
  }
26
27
  }
27
28
  }
28
29
  // 触发 webhook 投递(v1 同步 fetch,超时 5s)
29
30
  // 跨域 API — charity / RFQ / orders 等通过此函数广播事件
30
- export async function fireWebhooks(db, generateId, eventType, payload, userIds) {
31
+ export async function fireWebhooks(_db, generateId, eventType, payload, userIds) {
31
32
  const where = userIds && userIds.length
32
33
  ? `event_type = ? AND active = 1 AND user_id IN (${userIds.map(() => '?').join(',')})`
33
34
  : `event_type = ? AND active = 1`;
34
35
  const args = [eventType, ...(userIds || [])];
35
- const subs = db.prepare(`SELECT * FROM webhook_subscriptions WHERE ${where}`).all(...args);
36
+ const subs = await dbAll(`SELECT * FROM webhook_subscriptions WHERE ${where}`, args);
36
37
  for (const sub of subs) {
37
38
  // P1.1 SSRF:投递前再次校验(防止旧订阅或 DB 直改绕过创建时检查)
38
39
  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);
40
+ await dbRun(`UPDATE webhook_subscriptions SET fail_count = fail_count + 1, last_error = ?, active = 0 WHERE id = ?`, ['blocked: private/internal host', sub.id]);
41
41
  continue;
42
42
  }
43
43
  const body = JSON.stringify({ event: eventType, payload, ts: new Date().toISOString() });
@@ -52,21 +52,22 @@ export async function fireWebhooks(db, generateId, eventType, payload, userIds)
52
52
  });
53
53
  clearTimeout(tm);
54
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);
55
+ await dbRun(`UPDATE webhook_subscriptions SET fire_count = fire_count + 1, last_fired_at = datetime('now'), last_error = NULL WHERE id = ?`, [sub.id]);
56
56
  }
57
57
  else {
58
- recordWebhookFailure(db, generateId, sub, 'HTTP ' + r.status);
58
+ await recordWebhookFailure(_db, generateId, sub, 'HTTP ' + r.status);
59
59
  }
60
60
  }
61
61
  catch (e) {
62
- recordWebhookFailure(db, generateId, sub, String(e.message).slice(0, 200));
62
+ await recordWebhookFailure(_db, generateId, sub, String(e.message).slice(0, 200));
63
63
  }
64
64
  }
65
65
  }
66
66
  export function registerWebhookRoutes(app, deps) {
67
- const { db, auth, generateId, rateLimitOk } = deps;
67
+ // db 已走 RFC-016 异步 seam(dbOne/dbAll/dbRun),不再直接用 deps.db
68
+ const { auth, generateId, rateLimitOk } = deps;
68
69
  // POST 订阅
69
- app.post('/api/webhooks', (req, res) => {
70
+ app.post('/api/webhooks', async (req, res) => {
70
71
  const user = auth(req, res);
71
72
  if (!user)
72
73
  return;
@@ -86,40 +87,39 @@ export function registerWebhookRoutes(app, deps) {
86
87
  if (!WEBHOOK_EVENT_TYPES.includes(eventType))
87
88
  return void res.json({ error: '不支持的 event_type' });
88
89
  // 每用户最多 20 个订阅
89
- const cnt = db.prepare(`SELECT COUNT(1) as n FROM webhook_subscriptions WHERE user_id = ?`).get(user.id).n;
90
+ const cnt = (await dbOne(`SELECT COUNT(1) as n FROM webhook_subscriptions WHERE user_id = ?`, [user.id])).n;
90
91
  if (cnt >= 20)
91
92
  return void res.json({ error: '订阅数量上限 20' });
92
93
  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);
94
+ await dbRun(`INSERT INTO webhook_subscriptions (id, user_id, event_type, target_url, secret) VALUES (?,?,?,?,?)`, [id, user.id, eventType, url, secret]);
95
95
  res.json({ id, event_type: eventType, target_url: url });
96
96
  });
97
97
  // GET 我的订阅
98
- app.get('/api/webhooks', (req, res) => {
98
+ app.get('/api/webhooks', async (req, res) => {
99
99
  const user = auth(req, res);
100
100
  if (!user)
101
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);
102
+ const items = await dbAll(`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`, [user.id]);
104
104
  res.json({ items, event_types: WEBHOOK_EVENT_TYPES });
105
105
  });
106
106
  // DELETE
107
- app.delete('/api/webhooks/:id', (req, res) => {
107
+ app.delete('/api/webhooks/:id', async (req, res) => {
108
108
  const user = auth(req, res);
109
109
  if (!user)
110
110
  return;
111
- const r = db.prepare(`DELETE FROM webhook_subscriptions WHERE id = ? AND user_id = ?`).run(req.params.id, user.id);
111
+ const r = await dbRun(`DELETE FROM webhook_subscriptions WHERE id = ? AND user_id = ?`, [req.params.id, user.id]);
112
112
  if (r.changes === 0)
113
113
  return void res.json({ error: '订阅不存在或非你所有' });
114
114
  res.json({ ok: true });
115
115
  });
116
116
  // PATCH active toggle
117
- app.patch('/api/webhooks/:id', (req, res) => {
117
+ app.patch('/api/webhooks/:id', async (req, res) => {
118
118
  const user = auth(req, res);
119
119
  if (!user)
120
120
  return;
121
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);
122
+ const r = await dbRun(`UPDATE webhook_subscriptions SET active = ? WHERE id = ? AND user_id = ?`, [active, req.params.id, user.id]);
123
123
  if (r.changes === 0)
124
124
  return void res.json({ error: '订阅不存在' });
125
125
  res.json({ ok: true, active });
@@ -1,10 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
2
3
  const SUB_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3
4
  const VALID_ROLE_PREFS = new Set(['buyer', 'seller', 'creator', 'verifier', 'arbitrator', 'other']);
4
5
  export function registerWelcomeRoutes(app, deps) {
5
- const { db, generateId, getUser, clientIpHash, clientUaHash, requireSupportAdmin } = deps;
6
+ // db 已走 RFC-016 异步 seam(dbOne/dbAll/dbRun),不再直接用 deps.db
7
+ const { generateId, getUser, clientIpHash, clientUaHash, requireSupportAdmin } = deps;
6
8
  // ─── admin 端 ─────────────────────────────────────────────
7
- app.get('/api/admin/public-ideas', (req, res) => {
9
+ app.get('/api/admin/public-ideas', async (req, res) => {
8
10
  const admin = requireSupportAdmin(req, res);
9
11
  if (!admin)
10
12
  return;
@@ -16,47 +18,45 @@ export function registerWelcomeRoutes(app, deps) {
16
18
  args.push(status);
17
19
  }
18
20
  const whereClause = `WHERE ${where.join(' AND ')}`;
19
- const rows = db.prepare(`
21
+ const rows = await dbAll(`
20
22
  SELECT id, user_id, contact, content, status, created_at
21
23
  FROM public_ideas ${whereClause}
22
24
  ORDER BY created_at DESC LIMIT 500
23
- `).all(...args);
24
- const counts = db.prepare(`SELECT
25
+ `, args);
26
+ const counts = await dbOne(`SELECT
25
27
  SUM(CASE WHEN status='new' THEN 1 ELSE 0 END) as st_new,
26
28
  SUM(CASE WHEN status='triaged' THEN 1 ELSE 0 END) as st_triaged,
27
29
  SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as st_resolved,
28
30
  SUM(CASE WHEN status='spam' THEN 1 ELSE 0 END) as st_spam,
29
31
  COUNT(*) as total
30
- FROM public_ideas WHERE content NOT LIKE 'WELCOME_EMAIL_SUBSCRIBE:%'`).get();
32
+ FROM public_ideas WHERE content NOT LIKE 'WELCOME_EMAIL_SUBSCRIBE:%'`);
31
33
  // P1 审计:admin 读 PII 留痕
32
34
  try {
33
- db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
34
- VALUES (?, ?, 'read_public_ideas', 'public_ideas', NULL, ?)`)
35
- .run(generateId('aud'), admin.id, JSON.stringify({ count: rows.length, status }));
35
+ await dbRun(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
36
+ VALUES (?, ?, 'read_public_ideas', 'public_ideas', NULL, ?)`, [generateId('aud'), admin.id, JSON.stringify({ count: rows.length, status })]);
36
37
  }
37
38
  catch { }
38
39
  res.json({ items: rows, counts });
39
40
  });
40
- app.patch('/api/admin/public-ideas/:id', (req, res) => {
41
+ app.patch('/api/admin/public-ideas/:id', async (req, res) => {
41
42
  const admin = requireSupportAdmin(req, res);
42
43
  if (!admin)
43
44
  return;
44
45
  const newStatus = String(req.body?.status || '');
45
46
  if (!['new', 'triaged', 'resolved', 'spam'].includes(newStatus))
46
47
  return void res.status(400).json({ error: 'status 取值非法' });
47
- const r = db.prepare("UPDATE public_ideas SET status=? WHERE id=?").run(newStatus, req.params.id);
48
+ const r = await dbRun("UPDATE public_ideas SET status=? WHERE id=?", [newStatus, req.params.id]);
48
49
  if (r.changes === 0)
49
50
  return void res.status(404).json({ error: '记录不存在' });
50
51
  try {
51
- db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
52
- VALUES (?, ?, 'patch_public_idea', 'public_ideas', ?, ?)`)
53
- .run(generateId('aud'), admin.id, req.params.id, JSON.stringify({ status: newStatus }));
52
+ await dbRun(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
53
+ VALUES (?, ?, 'patch_public_idea', 'public_ideas', ?, ?)`, [generateId('aud'), admin.id, req.params.id, JSON.stringify({ status: newStatus })]);
54
54
  }
55
55
  catch { }
56
56
  res.json({ ok: true, status: newStatus });
57
57
  });
58
58
  // 2026-05-25 admin 查邮箱订阅 — 独立端点,与建议分开
59
- app.get('/api/admin/email-subscriptions', (req, res) => {
59
+ app.get('/api/admin/email-subscriptions', async (req, res) => {
60
60
  const admin = requireSupportAdmin(req, res);
61
61
  if (!admin)
62
62
  return;
@@ -72,13 +72,13 @@ export function registerWelcomeRoutes(app, deps) {
72
72
  args.push(statusFilter);
73
73
  }
74
74
  const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
75
- const rows = db.prepare(`
75
+ const rows = await dbAll(`
76
76
  SELECT id, email, source, role_preference, note, consent_at, unsubscribed_at, user_id, created_at,
77
77
  COALESCE(handle_status,'pending') as handle_status, handled_at
78
78
  FROM email_subscriptions ${where}
79
79
  ORDER BY created_at DESC LIMIT 500
80
- `).all(...args);
81
- const counts = db.prepare(`SELECT
80
+ `, args);
81
+ const counts = await dbOne(`SELECT
82
82
  COUNT(*) as total,
83
83
  SUM(CASE WHEN unsubscribed_at IS NULL THEN 1 ELSE 0 END) as active,
84
84
  SUM(CASE WHEN unsubscribed_at IS NOT NULL THEN 1 ELSE 0 END) as unsubscribed,
@@ -86,17 +86,16 @@ export function registerWelcomeRoutes(app, deps) {
86
86
  SUM(CASE WHEN handle_status = 'contacted' THEN 1 ELSE 0 END) as st_contacted,
87
87
  SUM(CASE WHEN handle_status = 'invited' THEN 1 ELSE 0 END) as st_invited,
88
88
  SUM(CASE WHEN handle_status = 'done' THEN 1 ELSE 0 END) as st_done
89
- FROM email_subscriptions`).get();
89
+ FROM email_subscriptions`);
90
90
  try {
91
- db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
92
- VALUES (?, ?, 'read_email_subscriptions', 'email_subscriptions', NULL, ?)`)
93
- .run(generateId('aud'), admin.id, JSON.stringify({ count: rows.length, include_unsubscribed: includeUnsub }));
91
+ await dbRun(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
92
+ VALUES (?, ?, 'read_email_subscriptions', 'email_subscriptions', NULL, ?)`, [generateId('aud'), admin.id, JSON.stringify({ count: rows.length, include_unsubscribed: includeUnsub })]);
94
93
  }
95
94
  catch { }
96
95
  res.json({ items: rows, counts });
97
96
  });
98
97
  // 2026-05-29: admin 标记申请处理状态(pending→contacted→invited→done)— 不动 POST 提交逻辑
99
- app.patch('/api/admin/email-subscriptions/:id/status', (req, res) => {
98
+ app.patch('/api/admin/email-subscriptions/:id/status', async (req, res) => {
100
99
  const admin = requireSupportAdmin(req, res);
101
100
  if (!admin)
102
101
  return;
@@ -104,15 +103,13 @@ export function registerWelcomeRoutes(app, deps) {
104
103
  const status = String((req.body || {}).status || '');
105
104
  if (!HANDLE_STATES.includes(status))
106
105
  return void res.status(400).json({ error: 'status 必须是 pending/contacted/invited/done' });
107
- const row = db.prepare('SELECT id, handle_status FROM email_subscriptions WHERE id = ?').get(req.params.id);
106
+ const row = await dbOne('SELECT id, handle_status FROM email_subscriptions WHERE id = ?', [req.params.id]);
108
107
  if (!row)
109
108
  return void res.status(404).json({ error: '记录不存在' });
110
- db.prepare("UPDATE email_subscriptions SET handle_status = ?, handled_at = datetime('now'), handled_by = ? WHERE id = ?")
111
- .run(status, admin.id, req.params.id);
109
+ await dbRun("UPDATE email_subscriptions SET handle_status = ?, handled_at = datetime('now'), handled_by = ? WHERE id = ?", [status, admin.id, req.params.id]);
112
110
  try {
113
- db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
114
- VALUES (?, ?, 'update_email_subscription_status', 'email_subscriptions', ?, ?)`)
115
- .run(generateId('aud'), admin.id, req.params.id, JSON.stringify({ from: row.handle_status || 'pending', to: status }));
111
+ await dbRun(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail)
112
+ VALUES (?, ?, 'update_email_subscription_status', 'email_subscriptions', ?, ?)`, [generateId('aud'), admin.id, req.params.id, JSON.stringify({ from: row.handle_status || 'pending', to: status })]);
116
113
  }
117
114
  catch { }
118
115
  res.json({ success: true, status });
@@ -120,7 +117,7 @@ export function registerWelcomeRoutes(app, deps) {
120
117
  // ─── 公开端 ───────────────────────────────────────────────
121
118
  // 2026-05-24 首屏「我有建议」— 公开提交(无需登录)
122
119
  // 反 bot:honeypot 字段 + 单 IP+UA 联合 rate limit 5/h + 内容 hash 去重 1h
123
- app.post('/api/public-ideas', (req, res) => {
120
+ app.post('/api/public-ideas', async (req, res) => {
124
121
  // 蜜罐字段 `_hp`:bot 倾向于填所有 input;真人不会看到(前端 display:none)
125
122
  if (req.body?._hp)
126
123
  return void res.status(400).json({ error: 'invalid' }); // 不告诉 bot 真原因
@@ -132,24 +129,23 @@ export function registerWelcomeRoutes(app, deps) {
132
129
  const ipHash = clientIpHash(req);
133
130
  const uaHash = clientUaHash(req);
134
131
  // IP+UA 联合:5/h(之前是仅 IP,NAT 误伤)
135
- const recent = db.prepare(`SELECT COUNT(*) as n FROM public_ideas
136
- WHERE (ip_hash = ? OR ua_hash = ?) AND created_at > datetime('now', '-1 hour')`).get(ipHash, uaHash).n;
132
+ const recent = (await dbOne(`SELECT COUNT(*) as n FROM public_ideas
133
+ WHERE (ip_hash = ? OR ua_hash = ?) AND created_at > datetime('now', '-1 hour')`, [ipHash, uaHash])).n;
137
134
  if (recent >= 5)
138
135
  return void res.status(429).json({ error: '提交过于频繁,请稍后再试' });
139
136
  // 内容去重:1h 内同 ip+content 不重复落库
140
- const dup = db.prepare(`SELECT 1 FROM public_ideas
141
- WHERE ip_hash = ? AND content = ? AND created_at > datetime('now', '-1 hour')`).get(ipHash, content);
137
+ const dup = await dbOne(`SELECT 1 FROM public_ideas
138
+ WHERE ip_hash = ? AND content = ? AND created_at > datetime('now', '-1 hour')`, [ipHash, content]);
142
139
  if (dup)
143
140
  return void res.status(409).json({ error: '请勿重复提交相同内容' });
144
141
  const userId = getUser(req)?.id || null;
145
142
  const id = generateId('idea');
146
- db.prepare(`INSERT INTO public_ideas (id, user_id, contact, content, ip_hash, ua_hash) VALUES (?,?,?,?,?,?)`)
147
- .run(id, userId, contact || null, content, ipHash, uaHash);
143
+ await dbRun(`INSERT INTO public_ideas (id, user_id, contact, content, ip_hash, ua_hash) VALUES (?,?,?,?,?,?)`, [id, userId, contact || null, content, ipHash, uaHash]);
148
144
  res.json({ ok: true, id });
149
145
  });
150
146
  // 2026-05-25 邮箱订阅独立端点(替代旧 WELCOME_EMAIL_SUBSCRIBE: 前缀 hack)
151
147
  // 2026-05-26 加 role_preference + note 字段(welcome 表单丰富化)
152
- app.post('/api/email-subscriptions', (req, res) => {
148
+ app.post('/api/email-subscriptions', async (req, res) => {
153
149
  if (req.body?._hp)
154
150
  return void res.status(400).json({ error: 'invalid' }); // honeypot
155
151
  const email = String(req.body?.email || '').trim().toLowerCase();
@@ -165,13 +161,13 @@ export function registerWelcomeRoutes(app, deps) {
165
161
  const note = noteRaw ? noteRaw.slice(0, 500) : null;
166
162
  const ipHash = clientIpHash(req);
167
163
  // rate limit: 单 IP 1h 最多 3 次(防爆破探测同人多邮箱)
168
- const recent = db.prepare(`SELECT COUNT(*) as n FROM email_subscriptions
169
- WHERE ip_hash = ? AND created_at > datetime('now', '-1 hour')`).get(ipHash).n;
164
+ const recent = (await dbOne(`SELECT COUNT(*) as n FROM email_subscriptions
165
+ WHERE ip_hash = ? AND created_at > datetime('now', '-1 hour')`, [ipHash])).n;
170
166
  if (recent >= 3)
171
167
  return void res.status(429).json({ error: '提交过于频繁,请稍后再试' });
172
168
  // 已存在(active)→ 幂等返回 ok(不暴露"该邮箱已订阅"避免邮箱枚举)
173
169
  // 但若提供了新 role/note,更新这俩字段(用户可能回来补充)
174
- const exist = db.prepare(`SELECT id, unsubscribed_at FROM email_subscriptions WHERE email = ?`).get(email);
170
+ const exist = await dbOne(`SELECT id, unsubscribed_at FROM email_subscriptions WHERE email = ?`, [email]);
175
171
  if (exist) {
176
172
  const sets = [];
177
173
  const args = [];
@@ -188,36 +184,35 @@ export function registerWelcomeRoutes(app, deps) {
188
184
  }
189
185
  if (sets.length > 0) {
190
186
  args.push(exist.id);
191
- db.prepare(`UPDATE email_subscriptions SET ${sets.join(', ')} WHERE id = ?`).run(...args);
187
+ await dbRun(`UPDATE email_subscriptions SET ${sets.join(', ')} WHERE id = ?`, args);
192
188
  }
193
189
  return void res.json({ ok: true, id: exist.id, status: 'subscribed' });
194
190
  }
195
191
  const id = generateId('eml');
196
192
  const token = createHash('sha256').update(id + email + Math.random()).digest('hex').slice(0, 32);
197
193
  const userId = getUser(req)?.id || null;
198
- db.prepare(`INSERT INTO email_subscriptions (id, email, source, role_preference, note, unsubscribe_token, ip_hash, user_id) VALUES (?,?,?,?,?,?,?,?)`)
199
- .run(id, email, source, rolePref, note, token, ipHash, userId);
194
+ await dbRun(`INSERT INTO email_subscriptions (id, email, source, role_preference, note, unsubscribe_token, ip_hash, user_id) VALUES (?,?,?,?,?,?,?,?)`, [id, email, source, rolePref, note, token, ipHash, userId]);
200
195
  res.json({ ok: true, id, status: 'subscribed', unsubscribe_url: `/unsubscribe?t=${token}` });
201
196
  });
202
197
  // 公开退订端点 — 接受 GET(邮件里的链接)+ POST(页面按钮)
203
- function doUnsubscribe(token) {
198
+ async function doUnsubscribe(token) {
204
199
  if (!token || token.length !== 32)
205
200
  return { ok: false, error: 'invalid_token' };
206
- const row = db.prepare(`SELECT id, email, unsubscribed_at FROM email_subscriptions WHERE unsubscribe_token = ?`).get(token);
201
+ const row = await dbOne(`SELECT id, email, unsubscribed_at FROM email_subscriptions WHERE unsubscribe_token = ?`, [token]);
207
202
  if (!row)
208
203
  return { ok: false, error: 'token_not_found' };
209
204
  if (row.unsubscribed_at)
210
205
  return { ok: true, email: row.email }; // 已退订也返 ok(幂等)
211
- db.prepare(`UPDATE email_subscriptions SET unsubscribed_at = datetime('now') WHERE id = ?`).run(row.id);
206
+ await dbRun(`UPDATE email_subscriptions SET unsubscribed_at = datetime('now') WHERE id = ?`, [row.id]);
212
207
  return { ok: true, email: row.email };
213
208
  }
214
- app.post('/api/email-subscriptions/unsubscribe', (req, res) => {
215
- const r = doUnsubscribe(String(req.body?.token || ''));
209
+ app.post('/api/email-subscriptions/unsubscribe', async (req, res) => {
210
+ const r = await doUnsubscribe(String(req.body?.token || ''));
216
211
  res.status(r.ok ? 200 : 400).json(r);
217
212
  });
218
213
  // 浏览器友好的退订页(GET)
219
- app.get('/unsubscribe', (req, res) => {
220
- const r = doUnsubscribe(String(req.query?.t || ''));
214
+ app.get('/unsubscribe', async (req, res) => {
215
+ const r = await doUnsubscribe(String(req.query?.t || ''));
221
216
  const okHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>已退订 — webaz</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#FAFAFA;color:#18181B}main{text-align:center;padding:32px}h1{font-size:22px;font-weight:600;margin:0 0 12px}p{font-size:14px;color:#71717A;margin:0 0 8px}a{color:#6366f1;text-decoration:none;font-size:13px}</style></head><body><main><h1>✓ 已退订 / Unsubscribed</h1><p>${r.email ? `<code>${r.email}</code>` : ''}</p><p>不会再收到 webaz 的邮件。<br>You won't receive emails from webaz anymore.</p><a href="/">← 返回首页 / Back</a></main></body></html>`;
222
217
  const errHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>退订链接无效</title><style>body{font-family:-apple-system,sans-serif;text-align:center;padding:80px 20px;color:#18181B}p{color:#71717A}</style></head><body><h1>退订链接无效</h1><p>${r.error}</p><a href="/">← 返回首页</a></body></html>`;
223
218
  res.setHeader('content-type', 'text/html; charset=utf-8');