@seasonkoh/webaz 0.1.16 → 0.1.18

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 (39) hide show
  1. package/README.md +60 -5
  2. package/dist/layer0-foundation/L0-2-state-machine/engine.js +3 -0
  3. package/dist/layer1-agent/L1-1-mcp-server/server.js +899 -720
  4. package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +287 -0
  5. package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +102 -0
  6. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +180 -0
  7. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +16 -0
  8. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +1 -0
  9. package/dist/mcp.js +7 -3
  10. package/dist/pwa/data/onboarding-cases.js +345 -0
  11. package/dist/pwa/data/onboarding-quiz.js +247 -0
  12. package/dist/pwa/public/app.js +1459 -96
  13. package/dist/pwa/public/i18n.js +303 -2
  14. package/dist/pwa/public/icon-192.png +0 -0
  15. package/dist/pwa/public/icon-512.png +0 -0
  16. package/dist/pwa/public/manifest.json +5 -2
  17. package/dist/pwa/public/openapi.json +1 -1
  18. package/dist/pwa/public/sw.js +1 -1
  19. package/dist/pwa/routes/admin-protocol-params.js +80 -2
  20. package/dist/pwa/routes/admin-reports.js +14 -9
  21. package/dist/pwa/routes/auth-read.js +3 -1
  22. package/dist/pwa/routes/build-feedback.js +82 -0
  23. package/dist/pwa/routes/build-reputation.js +10 -0
  24. package/dist/pwa/routes/build-tasks.js +73 -0
  25. package/dist/pwa/routes/disputes-write.js +149 -1
  26. package/dist/pwa/routes/governance-auto-deactivate.js +108 -0
  27. package/dist/pwa/routes/governance-onboarding.js +785 -0
  28. package/dist/pwa/routes/leaderboard.js +10 -2
  29. package/dist/pwa/routes/orders-action.js +5 -1
  30. package/dist/pwa/routes/products-meta.js +30 -0
  31. package/dist/pwa/routes/profile-identity.js +1 -1
  32. package/dist/pwa/routes/public-utils.js +44 -0
  33. package/dist/pwa/routes/rewards-apply.js +210 -0
  34. package/dist/pwa/routes/rewards-auto-downgrade.js +65 -0
  35. package/dist/pwa/routes/rewards-escrow-expire.js +48 -0
  36. package/dist/pwa/routes/wallet-write.js +17 -31
  37. package/dist/pwa/routes/webauthn.js +1 -1
  38. package/dist/pwa/server.js +641 -64
  39. package/package.json +6 -3
@@ -110,6 +110,10 @@ export function registerLeaderboardRoutes(app, deps) {
110
110
  // 2026-05-22 A3:仲裁员声誉排行
111
111
  // 从 dispute_cases 聚合 — 每个 arbitrator_id 的 case 数 + 公平评价
112
112
  // fairness_score = fairness_yes / (fairness_yes + fairness_no)(仅在有评价时)
113
+ //
114
+ // 2026-06-03 #1080 audit: ORDER BY 改为 case_count desc + u.id tie-breaker
115
+ // 移除 fairness_score 作为 secondary sort key — spec §3 禁 composite/multi-key
116
+ // ("display 4 separate dimensions, let user pick sort dimension")
113
117
  const rows = db.prepare(`
114
118
  SELECT u.id, u.handle, u.name,
115
119
  COUNT(dc.id) as cases_count,
@@ -124,13 +128,17 @@ export function registerLeaderboardRoutes(app, deps) {
124
128
  JOIN users u ON u.id = dc.arbitrator_id
125
129
  WHERE dc.arbitrator_id IS NOT NULL
126
130
  GROUP BY u.id
127
- ORDER BY cases_count DESC, fairness_score DESC LIMIT ?
131
+ ORDER BY cases_count DESC, u.id DESC LIMIT ?
128
132
  `).all(limit);
129
133
  return void res.json({ kind, items: rows });
130
134
  }
131
135
  if (kind === 'verifiers') {
132
136
  // 2026-05-22 V1:移除 tasks_done >= 5 门槛 — 小协议早期阶段会卡死榜单
133
137
  // 新人有 tasks_done < 5 时前端打 "新人" badge 区分(仍能看到自己排名)
138
+ //
139
+ // 2026-06-03 #1080 audit: ORDER BY 改为 tasks_done desc(spec default case_count desc)
140
+ // + u.id tie-breaker。移除 tasks_correct/accuracy 作为 secondary sort key — 该排序奖励
141
+ // "活跃 + 准确" 隐含 composite,spec §3 明确"最活跃 first ≠ 最好 first"。
134
142
  const rows = db.prepare(`
135
143
  SELECT u.id, u.handle, u.name,
136
144
  vs.tasks_done, vs.tasks_correct, vs.tasks_wrong,
@@ -140,7 +148,7 @@ export function registerLeaderboardRoutes(app, deps) {
140
148
  JOIN users u ON u.id = vs.user_id
141
149
  LEFT JOIN verifier_whitelist vw ON vw.user_id = vs.user_id
142
150
  WHERE vs.tasks_done >= 1
143
- ORDER BY vs.tasks_correct DESC, accuracy DESC, vs.tasks_done DESC LIMIT ?
151
+ ORDER BY vs.tasks_done DESC, u.id DESC LIMIT ?
144
152
  `).all(limit);
145
153
  return void res.json({ kind, items: rows });
146
154
  }
@@ -120,7 +120,11 @@ export function registerOrdersActionRoutes(app, deps) {
120
120
  if (action === 'pickup' || action === 'transit' || action === 'deliver') {
121
121
  // pickup 时若订单尚无物流,允许领取(孤儿单兜底)
122
122
  const isOrphanPickup = action === 'pickup' && !logisticsId;
123
- if (!isOrphanPickup && uid !== logisticsId) {
123
+ // Self-fulfill 兜底:logistics_id null seller 可驱动后续 transit/deliver
124
+ // 与 state machine VALID_TRANSITIONS allowedRoles=['seller','logistics'] 对齐
125
+ // (Phase 1: Logistics 市场尚未启用,seller 自履行是默认路径)
126
+ const isSelfFulfillTransition = !logisticsId && uid === sellerId;
127
+ if (!isOrphanPickup && !isSelfFulfillTransition && uid !== logisticsId) {
124
128
  return void res.status(403).json({ error: '你不是本订单的物流方', error_code: 'NOT_ORDER_LOGISTICS' });
125
129
  }
126
130
  }
@@ -133,6 +133,36 @@ export function registerProductsMetaRoutes(app, deps) {
133
133
  if (!user)
134
134
  return;
135
135
  const productId = req.params.id;
136
+ // RFC-002 §3.5 valuation-layer gate — share_link generation requires opt-in
137
+ const optIn = db.prepare("SELECT rewards_opted_in FROM users WHERE id = ?").get(user.id)?.rewards_opted_in ?? 0;
138
+ if (optIn !== 1) {
139
+ const getParam = (key, def) => {
140
+ const r = db.prepare("SELECT value FROM protocol_params WHERE key = ?").get(key);
141
+ return r ? Number(r.value) : def;
142
+ };
143
+ const minOrders = getParam('rewards_opt_in.min_completed_orders', 1);
144
+ const requirePasskey = getParam('rewards_opt_in.require_passkey', 1);
145
+ const totalCompleted = db.prepare("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'").get(user.id).n;
146
+ const passkeyCount = db.prepare("SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?").get(user.id).n;
147
+ const missing = [];
148
+ if (totalCompleted < minOrders)
149
+ missing.push(`completed_orders ${totalCompleted}/${minOrders}`);
150
+ if (requirePasskey === 1 && passkeyCount === 0)
151
+ missing.push('passkey_not_registered');
152
+ if (missing.length === 0)
153
+ missing.push('application_not_submitted');
154
+ return void res.status(403).json({
155
+ error: 'rewards_opt_in_required',
156
+ message_zh: '生成分享链接属于估值层操作 — 需先申请共建身份(RFC-002)',
157
+ message_en: 'Share-link generation is a valuation-layer action — requires builder-identity opt-in (RFC-002)',
158
+ missing_requirements: missing,
159
+ next_steps: [
160
+ 'Open PWA #me → tap "申请共建身份 / Apply for builder identity"',
161
+ 'Read the 8-second disclosure (cannot skip)',
162
+ 'Submit application — pre-checks run server-side',
163
+ ],
164
+ });
165
+ }
136
166
  const completed = db.prepare("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND product_id = ? AND status = 'completed'").get(user.id, productId).n;
137
167
  if (completed === 0)
138
168
  return void res.json({ error: '需先完成该商品的购买才能分享', completed_orders: 0 });
@@ -86,7 +86,7 @@ export function registerProfileIdentityRoutes(app, deps) {
86
86
  if (sinceMs < COOLDOWN_MS) {
87
87
  const remainDays = Math.ceil((COOLDOWN_MS - sinceMs) / (24 * 3600_000));
88
88
  return void res.status(429).json({
89
- error: `region 切换 30 天仅 1 次,请 ${remainDays} 天后再试(防止规避 MLM 合规限制)`,
89
+ error: `region 切换 30 天仅 1 次,请 ${remainDays} 天后再试(防止规避区域佣金规则)`,
90
90
  retry_after_days: remainDays,
91
91
  });
92
92
  }
@@ -204,6 +204,50 @@ export function registerPublicUtilsRoutes(app, deps) {
204
204
  app.get('/api/manifest', (_req, res) => {
205
205
  res.json(generateManifest(db));
206
206
  });
207
+ // W3.5-B 治理上岗公开 stats(docs/GOVERNANCE-ONBOARDING.md)
208
+ // 无 auth — agent / 用户 / 第三方都可读;不暴露 PII
209
+ app.get('/api/governance/onboarding-stats', (_req, res) => {
210
+ res.setHeader('Cache-Control', 'public, max-age=300');
211
+ try {
212
+ // active counts(users.roles 含 arbitrator / verifier 的人数,fixture 也算)
213
+ const arbitratorCount = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE roles LIKE '%arbitrator%' AND (deleted_at IS NULL OR deleted_at = '')`).get()?.n ?? 0;
214
+ const verifierCount = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE roles LIKE '%verifier%' AND (deleted_at IS NULL OR deleted_at = '')`).get()?.n ?? 0;
215
+ // pending applications
216
+ const pendingCount = db.prepare(`SELECT COUNT(*) AS n FROM governance_applications WHERE status = 'pending_onboarding'`).get()?.n ?? 0;
217
+ // 资格门槛 snapshot(给前端 pre-check 显示)
218
+ // ⚠️ 2026-06-03 #4 修:此前这里 dump 装饰性 protocol_params.governance_onboarding.*,
219
+ // 与代码实际 enforce 的门槛不符(例 min_completed_orders param=5,但代码 arbitrator 要 50 /
220
+ // verifier 要 20;arbitrator_min_reputation param=95,代码要 300)— 把错误数字当资格要求
221
+ // 显示给用户构成 #4 误导。改为返回【真实 enforced 门槛】,role-split。
222
+ // ⚠️ 必须与 server.ts checkArbitratorEligibility / checkVerifierEligibility 保持同步。
223
+ const eligibility = {
224
+ arbitrator: { registration_days: 90, completed_orders: 50, reputation: 300, balance_waz: 500, email_verified: true, zero_disputes_lost: true, never_suspended: true },
225
+ verifier: { registration_days: 60, completed_orders: 20, email_verified: true, zero_disputes_lost: true, never_suspended: true },
226
+ };
227
+ // quiz_pass_score 是真正被代码读取的 param(governance-onboarding.ts quiz-submit),保留。
228
+ const quizPassRow = db.prepare(`SELECT value FROM protocol_params WHERE key = 'governance_onboarding.quiz_pass_score'`).get();
229
+ const quizPassScore = Number(quizPassRow?.value ?? 80);
230
+ res.json({
231
+ phase: 'A',
232
+ compensation: 'none', // phase A 无报酬
233
+ observation_only: true, // leaderboard observation-only
234
+ active_arbitrators: arbitratorCount,
235
+ active_verifiers: verifierCount,
236
+ pending_applications: pendingCount,
237
+ eligibility,
238
+ quiz_pass_score: quizPassScore,
239
+ spec_urls: {
240
+ onboarding: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/GOVERNANCE-ONBOARDING.md',
241
+ playbook: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/ARBITRATION-PLAYBOOK.md',
242
+ leaderboard: 'https://github.com/seasonsagents-art/webaz/blob/main/docs/GOVERNANCE-LEADERBOARD-SPEC.md',
243
+ },
244
+ });
245
+ }
246
+ catch (e) {
247
+ logError('governance-onboarding-stats', e.message);
248
+ res.status(500).json({ error: 'internal_error' });
249
+ }
250
+ });
207
251
  const _errorReportLimiter = new Map();
208
252
  app.post('/api/error-report', (req, res) => {
209
253
  const ip = req.ip || 'unknown';
@@ -0,0 +1,210 @@
1
+ import { createHash } from 'node:crypto';
2
+ function sha256_hex(s) {
3
+ return createHash('sha256').update(s).digest('hex');
4
+ }
5
+ export function registerRewardsApplyRoutes(app, deps) {
6
+ const { db, auth, errorRes, consumeGateToken, getProtocolParam } = deps;
7
+ function expectedApplyConsentHash(userId, consentVersion, pageLoadedAt) {
8
+ return sha256_hex(`rewards_apply|consent_version=${consentVersion}|user=${userId}|page_loaded_at=${pageLoadedAt}`);
9
+ }
10
+ // GET /api/rewards/status — current state + escrow tally
11
+ app.get('/api/rewards/status', (req, res) => {
12
+ const user = auth(req, res);
13
+ if (!user)
14
+ return;
15
+ const userId = user.id;
16
+ const optIn = db.prepare("SELECT rewards_opted_in FROM users WHERE id = ?").get(userId)?.rewards_opted_in ?? 0;
17
+ const lastAction = db.prepare("SELECT action, created_at FROM rewards_applications WHERE user_id = ? ORDER BY created_at DESC LIMIT 1").get(userId);
18
+ const currentMajor = db.prepare("SELECT version, hash, change_class, effective_at, text_zh, text_en FROM rewards_consent_texts WHERE change_class='major' ORDER BY effective_at DESC LIMIT 1").get();
19
+ let state;
20
+ if (optIn === 1)
21
+ state = 'opted_in';
22
+ else if (lastAction?.action === 'deactivate')
23
+ state = 'deactivated';
24
+ else if (lastAction?.action === 'auto_downgrade')
25
+ state = 'auto_downgraded';
26
+ else
27
+ state = 'never_activated';
28
+ const completedOrders = db.prepare("SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND status = 'completed'").get(userId).n;
29
+ const passkeyCount = db.prepare("SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?").get(userId).n;
30
+ const minOrders = Number(getProtocolParam('rewards_opt_in.min_completed_orders', 1));
31
+ const requirePasskey = Number(getProtocolParam('rewards_opt_in.require_passkey', 1));
32
+ const delaySec = Number(getProtocolParam('rewards_opt_in.consent_delay_seconds', 8));
33
+ const missing = [];
34
+ if (completedOrders < minOrders)
35
+ missing.push(`completed_orders ${completedOrders}/${minOrders}`);
36
+ if (requirePasskey === 1 && passkeyCount === 0)
37
+ missing.push('passkey_not_registered');
38
+ const pending = db.prepare("SELECT COUNT(*) AS n, COALESCE(SUM(amount),0) AS total FROM pending_commission_escrow WHERE recipient_user_id = ? AND status = 'pending'").get(userId);
39
+ const expired = db.prepare("SELECT COUNT(*) AS n, COALESCE(SUM(amount),0) AS total FROM pending_commission_escrow WHERE recipient_user_id = ? AND status = 'expired'").get(userId);
40
+ res.json({
41
+ state,
42
+ opted_in: optIn === 1,
43
+ consent_version: currentMajor?.version || null,
44
+ consent_hash: currentMajor?.hash || null,
45
+ consent_effective_at: currentMajor?.effective_at || null,
46
+ consent_text_zh: currentMajor?.text_zh || null,
47
+ consent_text_en: currentMajor?.text_en || null,
48
+ eligibility: {
49
+ completed_orders: completedOrders,
50
+ min_completed_orders: minOrders,
51
+ passkey_count: passkeyCount,
52
+ require_passkey: requirePasskey === 1,
53
+ consent_delay_seconds: delaySec,
54
+ missing,
55
+ can_apply: optIn === 0 && missing.length === 0,
56
+ },
57
+ pending_escrow: { count: pending.n, total_amount: pending.total },
58
+ expired_to_charity: { count: expired.n, total_amount: expired.total },
59
+ last_action: lastAction ? { action: lastAction.action, at: lastAction.created_at } : null,
60
+ });
61
+ });
62
+ // POST /api/rewards/apply — activate (or reconfirm) opt-in + drain escrow
63
+ app.post('/api/rewards/apply', (req, res) => {
64
+ const user = auth(req, res);
65
+ if (!user)
66
+ return;
67
+ const userId = user.id;
68
+ const body = req.body || {};
69
+ const consent_version = String(body.consent_version || '');
70
+ const consent_hash = String(body.consent_hash || '');
71
+ const page_loaded_at = Number(body.page_loaded_at || 0);
72
+ const webauthn_token = body.webauthn_token ? String(body.webauthn_token) : undefined;
73
+ // 1. Verify currently opted-out
74
+ const optIn = db.prepare("SELECT rewards_opted_in FROM users WHERE id = ?").get(userId)?.rewards_opted_in ?? 0;
75
+ if (optIn === 1)
76
+ return void errorRes(res, 409, 'ALREADY_OPTED_IN', '已 opted-in,无需重复申请');
77
+ // 2. Verify consent version matches current major
78
+ const currentMajor = db.prepare("SELECT version, hash FROM rewards_consent_texts WHERE change_class='major' ORDER BY effective_at DESC LIMIT 1").get();
79
+ if (!currentMajor)
80
+ return void errorRes(res, 500, 'NO_CONSENT_TEXT', 'rewards_consent_texts 未 seed,无法申请');
81
+ if (consent_version !== currentMajor.version) {
82
+ return void errorRes(res, 400, 'STALE_CONSENT_VERSION', `请重新加载披露页 — current=${currentMajor.version}, you sent=${consent_version}`);
83
+ }
84
+ // 3. Anti-induction 8s delay (with upper bound to defeat page_loaded_at=1 bypass)
85
+ const delaySec = Number(getProtocolParam('rewards_opt_in.consent_delay_seconds', 8));
86
+ const minDelayMs = delaySec * 1000;
87
+ const maxDelayMs = 60 * 60 * 1000; // 1h — session shouldn't be older than this
88
+ if (page_loaded_at <= 0)
89
+ return void errorRes(res, 400, 'MISSING_PAGE_LOADED_AT', 'page_loaded_at 缺失(反诱导校验)');
90
+ const elapsedMs = Date.now() - page_loaded_at;
91
+ if (elapsedMs < minDelayMs) {
92
+ const waitSec = Math.ceil((minDelayMs - elapsedMs) / 1000);
93
+ return void errorRes(res, 400, 'ANTI_INDUCTION_DELAY', `必须等待 ${waitSec}s 后才能提交(反诱导)`);
94
+ }
95
+ if (elapsedMs > maxDelayMs) {
96
+ return void errorRes(res, 400, 'STALE_PAGE_LOAD', '披露页过期(> 1h),请重新加载');
97
+ }
98
+ // 4. Verify consent_hash reconstructed from server-known fields
99
+ const expectedHash = expectedApplyConsentHash(userId, consent_version, page_loaded_at);
100
+ if (consent_hash !== expectedHash) {
101
+ return void errorRes(res, 400, 'BAD_CONSENT_HASH', 'consent_hash 不匹配 — 请重新加载披露页');
102
+ }
103
+ // 5. Pre-conditions (re-check inside server)
104
+ const minOrders = Number(getProtocolParam('rewards_opt_in.min_completed_orders', 1));
105
+ const completedOrders = db.prepare("SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND status = 'completed'").get(userId).n;
106
+ if (completedOrders < minOrders)
107
+ return void errorRes(res, 403, 'INSUFFICIENT_ORDERS', `需 ${minOrders} 笔已完成订单,目前 ${completedOrders}`);
108
+ const requirePasskey = Number(getProtocolParam('rewards_opt_in.require_passkey', 1));
109
+ const passkeyCount = db.prepare("SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?").get(userId).n;
110
+ if (requirePasskey === 1 && passkeyCount === 0)
111
+ return void errorRes(res, 403, 'PASSKEY_REQUIRED', '需先注册 Passkey');
112
+ // 6. Atomic: consume Passkey gate + insert audit + flip flag + drain escrow → wallet
113
+ // consumeGateToken is moved INSIDE the transaction so a downstream rollback
114
+ // also rolls back the consumed_at mark (user can retry without re-doing Passkey).
115
+ let drained = { count: 0, total: 0 };
116
+ let raceLost = false;
117
+ let gateFailReason = null;
118
+ try {
119
+ db.transaction(() => {
120
+ if (requirePasskey === 1) {
121
+ const gate = consumeGateToken(userId, webauthn_token, 'rewards_apply', () => true);
122
+ if (!gate.ok) {
123
+ gateFailReason = gate.reason || 'Passkey 验证失败';
124
+ throw new Error('gate_failed');
125
+ }
126
+ }
127
+ const now = Date.now();
128
+ // Race guard: flip flag only if still 0. Concurrent tabs / replay would
129
+ // see changes=0 here and roll back the whole transaction.
130
+ const flip = db.prepare("UPDATE users SET rewards_opted_in = 1 WHERE id = ? AND rewards_opted_in = 0").run(userId);
131
+ if (flip.changes === 0) {
132
+ raceLost = true;
133
+ throw new Error('race_lost');
134
+ }
135
+ db.prepare(`INSERT INTO rewards_applications (user_id, action, consent_version, consent_hash, passkey_sig, verification_method, ip_hash, ua_hash, created_at)
136
+ VALUES (?, 'activate', ?, ?, ?, ?, ?, ?, ?)`)
137
+ .run(userId, consent_version, currentMajor.hash, webauthn_token || null, // store gate_token id as audit cross-ref to webauthn_gate_tokens
138
+ requirePasskey === 1 ? 'passkey' : 'password', sha256_hex(req.ip || '').slice(0, 16), sha256_hex(String(req.headers['user-agent'] || '')).slice(0, 16), now);
139
+ // Activate batch settle: drain pending escrow to wallet
140
+ const pending = db.prepare(`SELECT id, amount, attribution_path FROM pending_commission_escrow
141
+ WHERE recipient_user_id = ? AND status = 'pending' AND expires_at > ?`).all(userId, now);
142
+ let total = 0;
143
+ for (const p of pending) {
144
+ const upd = db.prepare(`UPDATE pending_commission_escrow SET status='settled', settled_at=? WHERE id=? AND status='pending'`).run(now, p.id);
145
+ if (upd.changes === 0)
146
+ continue; // race: expire cron took it
147
+ db.prepare(`UPDATE wallets SET balance = balance + ?, earned = earned + ? WHERE user_id = ?`).run(p.amount, p.amount, userId);
148
+ // #1106:pv_pair escrow 的钱在结算时已从 pool 移入 pv_escrow_reserve,兑付从 reserve 出。
149
+ // L1/L2/L3 commission escrow 的钱在下单结算时已从 seller 扣(不在任何池),兑付无需动池/reserve。
150
+ if (p.attribution_path === 'pv_pair') {
151
+ db.prepare(`UPDATE global_fund SET pv_escrow_reserve = pv_escrow_reserve - ? WHERE id = 1`).run(p.amount);
152
+ }
153
+ total += p.amount;
154
+ }
155
+ drained = { count: pending.length, total: Math.round(total * 100) / 100 };
156
+ })();
157
+ }
158
+ catch (e) {
159
+ if (gateFailReason)
160
+ return void errorRes(res, 403, 'PASSKEY_GATE_FAILED', gateFailReason);
161
+ if (raceLost)
162
+ return void errorRes(res, 409, 'CONCURRENT_APPLY', '已被另一并发请求 opt-in,无需重复');
163
+ return void errorRes(res, 500, 'APPLY_FAILED', e.message);
164
+ }
165
+ res.json({ ok: true, state: 'opted_in', drained_from_escrow: drained });
166
+ });
167
+ // POST /api/rewards/deactivate — flip off; subsequent commissions → charity
168
+ app.post('/api/rewards/deactivate', (req, res) => {
169
+ const user = auth(req, res);
170
+ if (!user)
171
+ return;
172
+ const userId = user.id;
173
+ const body = req.body || {};
174
+ const webauthn_token = body.webauthn_token ? String(body.webauthn_token) : undefined;
175
+ const optIn = db.prepare("SELECT rewards_opted_in FROM users WHERE id = ?").get(userId)?.rewards_opted_in ?? 0;
176
+ if (optIn === 0)
177
+ return void errorRes(res, 409, 'ALREADY_OPTED_OUT', '本来就未 opted-in,无需关闭');
178
+ const requirePasskey = Number(getProtocolParam('rewards_opt_in.require_passkey', 1));
179
+ let raceLost = false;
180
+ let gateFailReason = null;
181
+ try {
182
+ db.transaction(() => {
183
+ if (requirePasskey === 1) {
184
+ const gate = consumeGateToken(userId, webauthn_token, 'rewards_deactivate', () => true);
185
+ if (!gate.ok) {
186
+ gateFailReason = gate.reason || 'Passkey 验证失败';
187
+ throw new Error('gate_failed');
188
+ }
189
+ }
190
+ const now = Date.now();
191
+ const flip = db.prepare("UPDATE users SET rewards_opted_in = 0 WHERE id = ? AND rewards_opted_in = 1").run(userId);
192
+ if (flip.changes === 0) {
193
+ raceLost = true;
194
+ throw new Error('race_lost');
195
+ }
196
+ db.prepare(`INSERT INTO rewards_applications (user_id, action, passkey_sig, verification_method, ip_hash, ua_hash, created_at)
197
+ VALUES (?, 'deactivate', ?, ?, ?, ?, ?)`)
198
+ .run(userId, webauthn_token || null, requirePasskey === 1 ? 'passkey' : 'password', sha256_hex(req.ip || '').slice(0, 16), sha256_hex(String(req.headers['user-agent'] || '')).slice(0, 16), now);
199
+ })();
200
+ }
201
+ catch (e) {
202
+ if (gateFailReason)
203
+ return void errorRes(res, 403, 'PASSKEY_GATE_FAILED', gateFailReason);
204
+ if (raceLost)
205
+ return void errorRes(res, 409, 'CONCURRENT_DEACTIVATE', '已被另一并发请求 opt-out');
206
+ return void errorRes(res, 500, 'DEACTIVATE_FAILED', e.message);
207
+ }
208
+ res.json({ ok: true, state: 'deactivated' });
209
+ });
210
+ }
@@ -0,0 +1,65 @@
1
+ export function runAutoDowngradeSweep(deps) {
2
+ const { db, getProtocolParam } = deps;
3
+ // Current major consent
4
+ const currentMajor = db.prepare(`SELECT version, effective_at FROM rewards_consent_texts WHERE change_class='major' ORDER BY effective_at DESC LIMIT 1`).get();
5
+ if (!currentMajor)
6
+ return { scanned: 0, downgraded: [], skip_reason: 'no major consent text in rewards_consent_texts' };
7
+ const graceDays = Number(getProtocolParam('rewards_opt_in.reconfirm_grace_days', 14));
8
+ const now = Date.now();
9
+ const deadline = currentMajor.effective_at + graceDays * 86400 * 1000;
10
+ if (now < deadline)
11
+ return { scanned: 0, downgraded: [], skip_reason: `current_major ${currentMajor.version} grace not yet expired (deadline=${deadline})` };
12
+ // Candidates: opted-in users whose LATEST activate-or-reconfirm consent_version
13
+ // is older than the current major.
14
+ const candidates = db.prepare(`
15
+ SELECT u.id AS user_id, (
16
+ SELECT consent_version FROM rewards_applications
17
+ WHERE user_id = u.id AND action IN ('activate','reconfirm')
18
+ ORDER BY created_at DESC LIMIT 1
19
+ ) AS last_version
20
+ FROM users u WHERE u.rewards_opted_in = 1
21
+ `).all();
22
+ const downgraded = [];
23
+ for (const c of candidates) {
24
+ if (c.last_version === currentMajor.version)
25
+ continue;
26
+ db.transaction(() => {
27
+ // Re-verify inside the transaction. Between the SELECT and here, the
28
+ // user may have reconfirmed (PR-2 endpoint inserts a new row with
29
+ // consent_version=current_major). If so, flag stays 1 but our outer
30
+ // check would still flip it — wrongly. The transactional re-read
31
+ // closes this race window.
32
+ const fresh = db.prepare(`SELECT consent_version FROM rewards_applications WHERE user_id = ? AND action IN ('activate','reconfirm') ORDER BY created_at DESC LIMIT 1`).get(c.user_id);
33
+ if (fresh?.consent_version === currentMajor.version)
34
+ return; // user reconfirmed mid-flight
35
+ const upd = db.prepare(`UPDATE users SET rewards_opted_in = 0 WHERE id = ? AND rewards_opted_in = 1`).run(c.user_id);
36
+ if (upd.changes === 0)
37
+ return; // race: user already toggled out
38
+ db.prepare(`INSERT INTO rewards_applications (user_id, action, verification_method, created_at) VALUES (?, 'auto_downgrade', 'system_auto', ?)`).run(c.user_id, now);
39
+ // Notify user (PR-2b): tell them their consent expired + escrow accrual + how to recover.
40
+ // Failure here is non-fatal — notification is best-effort, downgrade itself is the source of truth.
41
+ try {
42
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body) VALUES (?, ?, 'rewards_auto_downgrade', ?, ?)`)
43
+ .run(`ntf_rwd_${c.user_id}_${now}`, c.user_id, '共建身份已自动降级 / Rewards auto-downgraded', `新 consent 版本 ${currentMajor.version} 未在 grace 期内重新确认。未来 commission 进入 escrow(30 天可激活领回)。前往 #rewards-me 重新申请。 / New consent ${currentMajor.version} not re-confirmed within grace window. Future commission flows to escrow (30d recovery window). Visit #rewards-me to re-apply.`);
44
+ }
45
+ catch { /* notifications schema diff between envs; best-effort */ }
46
+ downgraded.push({ user_id: c.user_id, last_version: c.last_version, current_major: currentMajor.version, effective_at: currentMajor.effective_at });
47
+ })();
48
+ }
49
+ return { scanned: candidates.length, downgraded };
50
+ }
51
+ export function startAutoDowngradeCron(deps) {
52
+ const ms = 24 * 60 * 60 * 1000; // 1d fixed
53
+ setInterval(() => {
54
+ try {
55
+ const r = runAutoDowngradeSweep(deps);
56
+ if (r.downgraded.length > 0) {
57
+ console.log(`[rewards-auto-downgrade] swept ${r.scanned}, downgraded ${r.downgraded.length}: ${r.downgraded.map(d => `${d.user_id} ${d.last_version || '(none)'}→${d.current_major}`).join(', ')}`);
58
+ }
59
+ }
60
+ catch (e) {
61
+ console.error('[rewards-auto-downgrade-cron]', e);
62
+ }
63
+ }, ms);
64
+ console.log('⏬ RFC-002 §3.10 auto-downgrade cron 已启动 (每 24h, anchor=consent_text major effective_at + grace_days)');
65
+ }
@@ -0,0 +1,48 @@
1
+ export function runEscrowExpireSweep(deps) {
2
+ const { db, redirectToCommissionReserve } = deps;
3
+ const now = Date.now();
4
+ const rows = db.prepare(`
5
+ SELECT id, recipient_user_id, order_id, amount, attribution_path, expires_at
6
+ FROM pending_commission_escrow
7
+ WHERE status = 'pending' AND expires_at <= ?
8
+ ORDER BY expires_at ASC
9
+ LIMIT 1000
10
+ `).all(now);
11
+ const expired = [];
12
+ for (const r of rows) {
13
+ db.transaction(() => {
14
+ const upd = db.prepare(`UPDATE pending_commission_escrow SET status='expired', expired_to_charity_at=? WHERE id=? AND status='pending'`).run(now, r.id);
15
+ if (upd.changes === 0)
16
+ return; // race lost — another sweep already took it
17
+ if (r.attribution_path === 'pv_pair') {
18
+ // #1106:pv_pair escrow 的钱结算时已从 pool 移入 pv_escrow_reserve。到期未兑付 → 退回 pool。
19
+ db.prepare(`UPDATE global_fund SET pv_escrow_reserve = pv_escrow_reserve - ?, pool_balance = pool_balance + ? WHERE id = 1`).run(r.amount, r.amount);
20
+ }
21
+ else {
22
+ // L1/L2/L3 commission escrow:seller 已被扣,到期 materialize 入 commission_reserve。
23
+ redirectToCommissionReserve(r.amount, 'redirect_escrow_expired', {
24
+ orderId: r.order_id,
25
+ fromUserId: r.recipient_user_id,
26
+ note: `escrow expired (${r.attribution_path}) — opted-out recipient never activated within grace window`,
27
+ });
28
+ }
29
+ expired.push({ id: r.id, recipient_user_id: r.recipient_user_id, order_id: r.order_id, amount: r.amount, attribution_path: r.attribution_path });
30
+ })();
31
+ }
32
+ return { scanned: rows.length, expired };
33
+ }
34
+ export function startEscrowExpireCron(deps) {
35
+ const ms = 60 * 60 * 1000; // 1h fixed (escrow_days is in days; sub-day granularity unnecessary)
36
+ setInterval(() => {
37
+ try {
38
+ const r = runEscrowExpireSweep(deps);
39
+ if (r.expired.length > 0) {
40
+ console.log(`[rewards-escrow-expire] swept ${r.scanned}, expired ${r.expired.length}: ${r.expired.map(e => `${e.recipient_user_id}/${e.attribution_path}/${e.amount}`).join(', ')}`);
41
+ }
42
+ }
43
+ catch (e) {
44
+ console.error('[rewards-escrow-expire-cron]', e);
45
+ }
46
+ }, ms);
47
+ console.log('💸 RFC-002 escrow expire cron 已启动 (每 1h, anchor=expires_at per §3.5b)');
48
+ }
@@ -125,14 +125,21 @@ export function registerWalletWriteRoutes(app, deps) {
125
125
  });
126
126
  }
127
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) {
128
+ // WebAuthn gate — #1115 全额对齐铁律:**所有**提现都要真人 Passkey 一次性 token。
129
+ // 资金转出 = 真人在场(spec §4 铁律,与 vote/arbitrate/agent_revoke 同档)。
130
+ // email-OTP agent 威胁模型下不足(agent 可读监护人收件箱);故弃用旧的"非 Passkey email 兜底"路径。
131
+ // 未注册 Passkey 的账户:不能提现,先去「安全」绑 Passkey(pre-launch 0 真用户,推动资金操作 Passkey 化)。
132
+ const hpEnabled = Number(getProtocolParam('require_human_presence_for_withdraw', 1)) === 1;
133
+ if (hpEnabled) {
134
+ const hasPasskeyRow = db.prepare('SELECT COUNT(*) as n FROM webauthn_credentials WHERE user_id = ?').get(user.id);
135
+ const hasPasskey = (hasPasskeyRow?.n || 0) > 0;
136
+ if (!hasPasskey) {
137
+ return void res.status(403).json({
138
+ error: '提现需先绑定 Passkey(资金转出需真人在场,铁律)。请到「安全」页绑定后再试。',
139
+ error_code: 'PASSKEY_REQUIRED_FOR_WITHDRAW',
140
+ requires_passkey_setup: true,
141
+ });
142
+ }
136
143
  const token = req.headers['x-webauthn-token'];
137
144
  const gate = consumeGateToken(user.id, token, 'withdraw', (data) => {
138
145
  const d = (data || {});
@@ -144,8 +151,7 @@ export function registerWalletWriteRoutes(app, deps) {
144
151
  webauthn_required: true,
145
152
  purpose: 'withdraw',
146
153
  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',
154
+ force_reason: 'iron_rule_withdraw',
149
155
  });
150
156
  }
151
157
  }
@@ -167,27 +173,7 @@ export function registerWalletWriteRoutes(app, deps) {
167
173
  const mins = Math.ceil((new Date(wl.activates_at.replace(' ', 'T') + 'Z').getTime() - Date.now()) / 60_000);
168
174
  return void res.json({ error: `该地址在冷却期内,约 ${mins} 分钟后可用(添加后 24h 强制冷却)` });
169
175
  }
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 处理)
176
+ // Passkey 已过真人门 即时扣款 + pending(admin 处理)。各金额一致(大额二次邮件确认已被 Passkey 取代)。
191
177
  const wid = generateId('wdr');
192
178
  db.prepare(`INSERT INTO withdrawal_requests (id, user_id, to_address, amount) VALUES (?,?,?,?)`)
193
179
  .run(wid, user.id, to_address, amountNum);
@@ -65,7 +65,7 @@ export function registerWebauthnRoutes(app, deps) {
65
65
  if (!user)
66
66
  return;
67
67
  const purpose = String(req.body?.purpose || '').trim();
68
- const allowed = new Set(['withdraw', 'change-password', 'reveal-key', 'region', 'delete_passkey']);
68
+ 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']);
69
69
  if (!allowed.has(purpose))
70
70
  return void res.status(400).json({ error: 'invalid purpose' });
71
71
  const purpose_data = req.body?.purpose_data ?? null;