@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.
- package/README.md +60 -5
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +3 -0
- package/dist/layer1-agent/L1-1-mcp-server/server.js +899 -720
- package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +287 -0
- package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +102 -0
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +180 -0
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +16 -0
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +1 -0
- package/dist/mcp.js +7 -3
- package/dist/pwa/data/onboarding-cases.js +345 -0
- package/dist/pwa/data/onboarding-quiz.js +247 -0
- package/dist/pwa/public/app.js +1459 -96
- package/dist/pwa/public/i18n.js +303 -2
- package/dist/pwa/public/icon-192.png +0 -0
- package/dist/pwa/public/icon-512.png +0 -0
- package/dist/pwa/public/manifest.json +5 -2
- package/dist/pwa/public/openapi.json +1 -1
- package/dist/pwa/public/sw.js +1 -1
- package/dist/pwa/routes/admin-protocol-params.js +80 -2
- package/dist/pwa/routes/admin-reports.js +14 -9
- package/dist/pwa/routes/auth-read.js +3 -1
- package/dist/pwa/routes/build-feedback.js +82 -0
- package/dist/pwa/routes/build-reputation.js +10 -0
- package/dist/pwa/routes/build-tasks.js +73 -0
- package/dist/pwa/routes/disputes-write.js +149 -1
- package/dist/pwa/routes/governance-auto-deactivate.js +108 -0
- package/dist/pwa/routes/governance-onboarding.js +785 -0
- package/dist/pwa/routes/leaderboard.js +10 -2
- package/dist/pwa/routes/orders-action.js +5 -1
- package/dist/pwa/routes/products-meta.js +30 -0
- package/dist/pwa/routes/profile-identity.js +1 -1
- package/dist/pwa/routes/public-utils.js +44 -0
- package/dist/pwa/routes/rewards-apply.js +210 -0
- package/dist/pwa/routes/rewards-auto-downgrade.js +65 -0
- package/dist/pwa/routes/rewards-escrow-expire.js +48 -0
- package/dist/pwa/routes/wallet-write.js +17 -31
- package/dist/pwa/routes/webauthn.js +1 -1
- package/dist/pwa/server.js +641 -64
- 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,
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// 未注册 Passkey
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
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;
|