@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
@@ -0,0 +1,785 @@
1
+ import { createHash } from 'crypto';
2
+ import { getQuestionsForRole, scoreQuiz } from '../data/onboarding-quiz.js';
3
+ import { getCasesForRole, getCasesForMaintainer, validateCaseReviews } from '../data/onboarding-cases.js';
4
+ // PR #22 review fix P1-2:披露文本版本(server 与 client 同步,变更披露需 bump)
5
+ // client 在 src/pwa/public/app.js 必须用相同 version
6
+ export const GOVERNANCE_APPLY_DISCLOSURE_VERSION = 'v1.0-2026-06-02';
7
+ export function registerGovernanceOnboardingRoutes(app, deps) {
8
+ const { db, generateId, auth, errorRes, checkArbitratorEligibility, checkVerifierEligibility, consumeGateToken, getProtocolParam, requireGovernanceAdmin, logAdminAction } = deps;
9
+ const sha256_16 = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
10
+ const sha256_hex = (s) => createHash('sha256').update(s).digest('hex');
11
+ // PR #22 review fix P1-2:server 重建期望的 consent_hash 与 client 提交比对
12
+ function expectedConsentHash(role, userId, pageLoadedAt) {
13
+ return sha256_hex(`governance_apply|disclosure=${GOVERNANCE_APPLY_DISCLOSURE_VERSION}|role=${role}|user=${userId}|page_loaded_at=${pageLoadedAt}`);
14
+ }
15
+ app.post('/api/governance/onboarding/apply', (req, res) => {
16
+ const user = auth(req, res);
17
+ if (!user)
18
+ return;
19
+ const userId = user.id;
20
+ const body = req.body || {};
21
+ const role = String(body.role || '');
22
+ const consent_hash = String(body.consent_hash || '');
23
+ const passkey_sig = body.passkey_sig ? String(body.passkey_sig) : null;
24
+ const iron_rule_method = String(body.iron_rule_method || 'passkey');
25
+ const page_loaded_at = Number(body.page_loaded_at || 0);
26
+ if (role !== 'arbitrator' && role !== 'verifier') {
27
+ return void errorRes(res, 400, 'INVALID_ROLE', "role 必须是 'arbitrator' 或 'verifier'");
28
+ }
29
+ if (!consent_hash || consent_hash.length < 16) {
30
+ return void errorRes(res, 400, 'MISSING_CONSENT', 'consent_hash 缺失或长度不足');
31
+ }
32
+ // 2026-06-02 #1094 audit fix:wire governance_onboarding.consent_delay_seconds(此前硬编码 8s)
33
+ const delaySec = Number(getProtocolParam('governance_onboarding.consent_delay_seconds', 8));
34
+ const minDelayMs = delaySec * 1000;
35
+ if (page_loaded_at === 0) {
36
+ return void errorRes(res, 400, 'MISSING_PAGE_LOADED_AT', 'page_loaded_at 缺失(反诱导校验)');
37
+ }
38
+ const elapsedMs = Date.now() - page_loaded_at;
39
+ if (elapsedMs < minDelayMs) {
40
+ const waitSec = Math.ceil((minDelayMs - elapsedMs) / 1000);
41
+ return void errorRes(res, 400, 'ANTI_INDUCTION_DELAY', `必须等待 ${waitSec}s 后才能提交(反诱导)`);
42
+ }
43
+ // PR #22 review fix P1-2:校验 consent_hash 内容(防"任意 16 字符过关")
44
+ // server 用 disclosure_version + role + userId + page_loaded_at 重建,与 client 提交比对
45
+ const expected = expectedConsentHash(role, userId, page_loaded_at);
46
+ if (consent_hash !== expected) {
47
+ return void errorRes(res, 400, 'INVALID_CONSENT_HASH', `consent_hash 不匹配当前披露文本(version=${GOVERNANCE_APPLY_DISCLOSURE_VERSION},检查 client / server 版本同步)`);
48
+ }
49
+ // PR #22 review fix P1-1:真验 Passkey 签发(spec §3.1 Iron-Rule 反诱导真人门)
50
+ // 调 consumeGateToken 消费 webauthn_gate_tokens 表中的 token
51
+ const hpEnabled = Number(getProtocolParam('require_human_presence_for_governance_apply', 1)) === 1;
52
+ if (hpEnabled) {
53
+ if (!passkey_sig) {
54
+ return void errorRes(res, 401, 'PASSKEY_REQUIRED', '需 Passkey 签发(Iron-Rule 真人门)');
55
+ }
56
+ const validate = (data) => {
57
+ // purpose_data 来自 frontend 的 requestPasskeyGate('governance_apply', { role, consent_hash })
58
+ if (!data || typeof data !== 'object')
59
+ return false;
60
+ const d = data;
61
+ return d.role === role && d.consent_hash === consent_hash;
62
+ };
63
+ const result = consumeGateToken(userId, passkey_sig, 'governance_apply', validate);
64
+ if (!result.ok) {
65
+ return void errorRes(res, 401, 'PASSKEY_INVALID', `Passkey 验证失败: ${result.reason || '未知'}`);
66
+ }
67
+ }
68
+ const existing = db.prepare(`
69
+ SELECT id, status, cooldown_until FROM governance_applications
70
+ WHERE user_id = ? AND role = ? AND status IN ('pending_onboarding', 'active', 'cooldown')
71
+ ORDER BY created_at DESC LIMIT 1
72
+ `).get(userId, role);
73
+ if (existing) {
74
+ if (existing.status === 'active') {
75
+ return void errorRes(res, 409, 'ALREADY_ACTIVE', `你已是 ${role},无需重复申请`);
76
+ }
77
+ if (existing.status === 'pending_onboarding') {
78
+ return void errorRes(res, 409, 'PENDING_EXISTS', `已有 ${role} 申请待审(${existing.id})`);
79
+ }
80
+ if (existing.status === 'cooldown' && existing.cooldown_until) {
81
+ const now = Math.floor(Date.now() / 1000);
82
+ if (existing.cooldown_until > now) {
83
+ const cooldownDate = new Date(existing.cooldown_until * 1000).toISOString().slice(0, 10);
84
+ return void errorRes(res, 409, 'IN_COOLDOWN', `卸任冷却期未结束,${cooldownDate} 后可重新申请`);
85
+ }
86
+ }
87
+ }
88
+ const elig = role === 'arbitrator'
89
+ ? checkArbitratorEligibility(userId)
90
+ : checkVerifierEligibility(userId);
91
+ if (!elig.eligible) {
92
+ const missing = elig.items.filter(i => !i.ok).map(i => i.key);
93
+ return void res.status(400).json({
94
+ error: '门槛未达标',
95
+ code: 'NOT_ELIGIBLE',
96
+ missing_requirements: missing,
97
+ eligibility: elig,
98
+ });
99
+ }
100
+ const id = generateId('gapp');
101
+ const ip = String(req.ip || req.headers['x-forwarded-for'] || 'unknown').split(',')[0].trim();
102
+ const ua = String(req.headers['user-agent'] || 'unknown');
103
+ db.prepare(`
104
+ INSERT INTO governance_applications
105
+ (id, user_id, role, action, status, consent_hash, passkey_sig, iron_rule_method, ip_hash, ua_hash)
106
+ VALUES (?, ?, ?, 'apply', 'pending_onboarding', ?, ?, ?, ?, ?)
107
+ `).run(id, userId, role, consent_hash, passkey_sig, iron_rule_method, sha256_16(ip), sha256_16(ua));
108
+ res.json({
109
+ success: true,
110
+ application_id: id,
111
+ status: 'pending_onboarding',
112
+ next_step: 'onboarding_learning',
113
+ note: 'Maintainer 将 review 你的申请。下一步:完成 onboarding 学习 + 案例分析 + 题目(本阶段未上线)',
114
+ });
115
+ });
116
+ app.get('/api/governance/onboarding/my', (req, res) => {
117
+ const user = auth(req, res);
118
+ if (!user)
119
+ return;
120
+ const userId = user.id;
121
+ const items = db.prepare(`
122
+ SELECT id, role, action, status, quiz_score, quiz_passed_at, cooldown_until, appeal_reason, appeal_resolution, created_at
123
+ FROM governance_applications
124
+ WHERE user_id = ?
125
+ ORDER BY created_at DESC
126
+ LIMIT 50
127
+ `).all(userId);
128
+ res.json({ items, count: items.length });
129
+ });
130
+ // GET /api/governance/onboarding/quiz?role=arbitrator|verifier
131
+ // 返回题库(已剥离 correct_answer 防泄题)
132
+ // 实施 docs/GOVERNANCE-ONBOARDING.md §4.3 题目
133
+ app.get('/api/governance/onboarding/quiz', (req, res) => {
134
+ const user = auth(req, res);
135
+ if (!user)
136
+ return;
137
+ const role = String(req.query.role || '');
138
+ if (role !== 'arbitrator' && role !== 'verifier') {
139
+ return void errorRes(res, 400, 'INVALID_ROLE', "role 必须是 'arbitrator' 或 'verifier'");
140
+ }
141
+ const questions = getQuestionsForRole(role);
142
+ res.json({ role, total: questions.length, questions });
143
+ });
144
+ // POST /api/governance/onboarding/quiz-submit
145
+ // 提交题目答案 → server-side scoring → 写 governance_applications.quiz_score
146
+ // body: { role, answers: [{question_id, answer}] }
147
+ app.post('/api/governance/onboarding/quiz-submit', (req, res) => {
148
+ const user = auth(req, res);
149
+ if (!user)
150
+ return;
151
+ const userId = user.id;
152
+ const body = req.body || {};
153
+ const role = String(body.role || '');
154
+ const answers = Array.isArray(body.answers) ? body.answers : [];
155
+ if (role !== 'arbitrator' && role !== 'verifier') {
156
+ return void errorRes(res, 400, 'INVALID_ROLE', "role 必须是 'arbitrator' 或 'verifier'");
157
+ }
158
+ if (answers.length === 0) {
159
+ return void errorRes(res, 400, 'MISSING_ANSWERS', 'answers 数组为空');
160
+ }
161
+ // 找用户的最新 pending_onboarding application(必须先 apply 才能提交 quiz)
162
+ const app_ = db.prepare(`
163
+ SELECT id, status FROM governance_applications
164
+ WHERE user_id = ? AND role = ? AND status = 'pending_onboarding'
165
+ ORDER BY created_at DESC LIMIT 1
166
+ `).get(userId, role);
167
+ if (!app_) {
168
+ return void errorRes(res, 404, 'NO_PENDING_APPLICATION', `未找到 ${role} 待审申请,请先提交申请`);
169
+ }
170
+ // 读 quiz_pass_score(protocol_params,默认 80)
171
+ const param = db.prepare("SELECT value FROM protocol_params WHERE key = ?").get('governance_onboarding.quiz_pass_score');
172
+ const passThreshold = param ? Number(param.value) : 80;
173
+ // 评分
174
+ const result = scoreQuiz(role, answers, passThreshold);
175
+ // 更新 quiz_score(只在分数有提升时更新,允许重考)
176
+ const existing = db.prepare("SELECT quiz_score, quiz_passed_at FROM governance_applications WHERE id = ?").get(app_.id);
177
+ const newScore = result.score_pct;
178
+ if (existing.quiz_score == null || newScore > existing.quiz_score) {
179
+ db.prepare("UPDATE governance_applications SET quiz_score = ? WHERE id = ?").run(newScore, app_.id);
180
+ }
181
+ // PR #22 review fix P1-3:quiz pass 推进环节状态(quiz_passed_at 时间戳)
182
+ // 一旦合格,记录时间戳;后续不变(即便重考更低分,也不抹掉已合格状态)
183
+ if (result.passed && !existing.quiz_passed_at) {
184
+ const now = Math.floor(Date.now() / 1000);
185
+ db.prepare("UPDATE governance_applications SET quiz_passed_at = ? WHERE id = ?").run(now, app_.id);
186
+ }
187
+ res.json({
188
+ success: true,
189
+ application_id: app_.id,
190
+ ...result,
191
+ pass_threshold: passThreshold,
192
+ quiz_passed_at: existing.quiz_passed_at || (result.passed ? Math.floor(Date.now() / 1000) : null),
193
+ note: result.passed
194
+ ? '题目合格,可等待 maintainer 激活(完成案例研读后)'
195
+ : `分数 ${newScore}% < ${passThreshold}%,可重试`,
196
+ });
197
+ });
198
+ // GET /api/governance/onboarding/cases?role=arbitrator|verifier
199
+ // 返回案例库(剥离 expected_verdict + key_principles 防泄答案;maintainer 视角才看)
200
+ // 实施 docs/GOVERNANCE-ONBOARDING.md §4.2 案例研读
201
+ app.get('/api/governance/onboarding/cases', (req, res) => {
202
+ const user = auth(req, res);
203
+ if (!user)
204
+ return;
205
+ const role = String(req.query.role || '');
206
+ if (role !== 'arbitrator' && role !== 'verifier') {
207
+ return void errorRes(res, 400, 'INVALID_ROLE', "role 必须是 'arbitrator' 或 'verifier'");
208
+ }
209
+ const cases = getCasesForRole(role);
210
+ res.json({ role, total: cases.length, cases });
211
+ });
212
+ // POST /api/governance/onboarding/case-review
213
+ // 提交全部案例 review(必须一次性提交所有案例,部分提交→ 400)
214
+ // body: { role, reviews: [{case_id, chosen_verdict, reasoning}] }
215
+ // 写 governance_applications.case_review_text(JSON string)
216
+ // 不立即评分 — maintainer 上岗签字前(阶段 3 #1093)对比 expected_verdict
217
+ app.post('/api/governance/onboarding/case-review', (req, res) => {
218
+ const user = auth(req, res);
219
+ if (!user)
220
+ return;
221
+ const userId = user.id;
222
+ const body = req.body || {};
223
+ const role = String(body.role || '');
224
+ const reviews = Array.isArray(body.reviews) ? body.reviews : [];
225
+ if (role !== 'arbitrator' && role !== 'verifier') {
226
+ return void errorRes(res, 400, 'INVALID_ROLE', "role 必须是 'arbitrator' 或 'verifier'");
227
+ }
228
+ if (reviews.length === 0) {
229
+ return void errorRes(res, 400, 'MISSING_REVIEWS', 'reviews 数组为空');
230
+ }
231
+ // 找用户的 pending_onboarding application
232
+ const app_ = db.prepare(`
233
+ SELECT id, status FROM governance_applications
234
+ WHERE user_id = ? AND role = ? AND status = 'pending_onboarding'
235
+ ORDER BY created_at DESC LIMIT 1
236
+ `).get(userId, role);
237
+ if (!app_) {
238
+ return void errorRes(res, 404, 'NO_PENDING_APPLICATION', `未找到 ${role} 待审申请,请先提交申请`);
239
+ }
240
+ // 结构校验:必须全部 case 都有 review + chosen_verdict 在选项内 + reasoning ≥ min_chars
241
+ const validation = validateCaseReviews(role, reviews);
242
+ if (!validation.ok) {
243
+ return void res.status(400).json({
244
+ error: '案例 review 不完整或格式错',
245
+ code: 'INVALID_REVIEWS',
246
+ errors: validation.errors,
247
+ });
248
+ }
249
+ // 写 case_review_text(JSON 序列化,覆盖式 — 重新提交覆盖旧版本)
250
+ const payload = {
251
+ submitted_at: Math.floor(Date.now() / 1000),
252
+ reviews: reviews.map(r => ({
253
+ case_id: r.case_id,
254
+ chosen_verdict: r.chosen_verdict,
255
+ reasoning: r.reasoning.trim(),
256
+ })),
257
+ };
258
+ db.prepare("UPDATE governance_applications SET case_review_text = ? WHERE id = ?")
259
+ .run(JSON.stringify(payload), app_.id);
260
+ res.json({
261
+ success: true,
262
+ application_id: app_.id,
263
+ submitted_count: reviews.length,
264
+ note: '案例 review 已提交,maintainer 上岗签字前会对比 expected verdict 评估 reasoning',
265
+ });
266
+ });
267
+ // ─── Admin (maintainer activation flow, #1093 阶段 3) ─────────
268
+ // spec docs/GOVERNANCE-ONBOARDING.md §4.4
269
+ // GET /api/admin/governance/applications — 列出 pending_onboarding(可筛 quiz_passed + has_case_review)
270
+ app.get('/api/admin/governance/applications', (req, res) => {
271
+ const admin = requireGovernanceAdmin(req, res);
272
+ if (!admin)
273
+ return;
274
+ const items = db.prepare(`
275
+ SELECT ga.id, ga.user_id, ga.role, ga.action, ga.status, ga.quiz_score, ga.quiz_passed_at,
276
+ CASE WHEN ga.case_review_text IS NOT NULL THEN 1 ELSE 0 END AS has_case_review,
277
+ ga.created_at,
278
+ u.name AS user_name, u.handle, u.region, u.email
279
+ FROM governance_applications ga
280
+ JOIN users u ON u.id = ga.user_id
281
+ WHERE ga.status = 'pending_onboarding' AND ga.action = 'apply'
282
+ ORDER BY ga.created_at ASC
283
+ LIMIT 100
284
+ `).all();
285
+ res.json({ items, count: items.length });
286
+ });
287
+ // GET /api/admin/governance/application/:id — 详情(含 expected_verdict 用于对比 — 仅 maintainer 看)
288
+ app.get('/api/admin/governance/application/:id', (req, res) => {
289
+ const admin = requireGovernanceAdmin(req, res);
290
+ if (!admin)
291
+ return;
292
+ const id = req.params.id;
293
+ const row = db.prepare(`
294
+ SELECT ga.*, u.name AS user_name, u.handle, u.email
295
+ FROM governance_applications ga
296
+ JOIN users u ON u.id = ga.user_id
297
+ WHERE ga.id = ?
298
+ `).get(id);
299
+ if (!row)
300
+ return void errorRes(res, 404, 'NOT_FOUND', 'application 不存在');
301
+ const role = row.role;
302
+ const cases_with_expected = getCasesForMaintainer(role);
303
+ let parsed_review = null;
304
+ try {
305
+ parsed_review = row.case_review_text ? JSON.parse(row.case_review_text) : null;
306
+ }
307
+ catch { }
308
+ res.json({
309
+ application: row,
310
+ cases_with_expected, // 含 expected_verdict + key_principles(只给 maintainer)
311
+ parsed_review, // 申请者提交的 reviews(JSON 解析)
312
+ });
313
+ });
314
+ // POST /api/admin/governance/activate — 激活上岗
315
+ // spec §4.4:
316
+ // 1. 代码自动 re-gate(eligibility 真验,非人肉)
317
+ // 2. quiz_passed_at IS NOT NULL + case_review_text IS NOT NULL 已完成 onboarding
318
+ // 3. Iron-Rule Passkey ceremony(maintainer 真人签发)
319
+ // 4. INSERT action='activate' row + users.roles 加 role
320
+ // 5. logAdminAction 留痕
321
+ // body: { application_id, webauthn_token, note? }
322
+ app.post('/api/admin/governance/activate', (req, res) => {
323
+ const admin = requireGovernanceAdmin(req, res);
324
+ if (!admin)
325
+ return;
326
+ const adminId = admin.id;
327
+ const body = req.body || {};
328
+ const application_id = String(body.application_id || '');
329
+ const webauthn_token = body.webauthn_token ? String(body.webauthn_token) : undefined;
330
+ const note = body.note ? String(body.note).slice(0, 1000) : null;
331
+ if (!application_id) {
332
+ return void errorRes(res, 400, 'MISSING_APPLICATION_ID', 'application_id 必填');
333
+ }
334
+ // 1. 找 pending application(predicate read,真 status check 在 transaction 内)
335
+ const app_ = db.prepare(`
336
+ SELECT id, user_id, role, status, quiz_passed_at, case_review_text
337
+ FROM governance_applications WHERE id = ?
338
+ `).get(application_id);
339
+ if (!app_)
340
+ return void errorRes(res, 404, 'NOT_FOUND', 'application 不存在');
341
+ if (app_.status !== 'pending_onboarding') {
342
+ return void errorRes(res, 409, 'WRONG_STATUS', `application status='${app_.status}',只能激活 pending_onboarding`);
343
+ }
344
+ // 2. 检查 onboarding 完成度(quiz + case review)
345
+ if (!app_.quiz_passed_at) {
346
+ return void errorRes(res, 400, 'QUIZ_NOT_PASSED', '申请者尚未通过 onboarding 题目');
347
+ }
348
+ if (!app_.case_review_text) {
349
+ return void errorRes(res, 400, 'CASE_REVIEW_MISSING', '申请者尚未提交案例 review');
350
+ }
351
+ // 3. ⚠️ 代码自动 re-gate(spec §4.4 — 非人肉,maintainer phase A solo 时不能记错)
352
+ const role = app_.role;
353
+ const elig = role === 'arbitrator'
354
+ ? checkArbitratorEligibility(app_.user_id)
355
+ : checkVerifierEligibility(app_.user_id);
356
+ if (!elig.eligible) {
357
+ const missing = elig.items.filter(i => !i.ok).map(i => i.key);
358
+ return void res.status(400).json({
359
+ error: '代码自动 re-gate 失败:eligibility 二次校验不通过',
360
+ code: 'ELIGIBILITY_REGATE_FAILED',
361
+ missing_requirements: missing,
362
+ eligibility: elig,
363
+ note: 'spec §4.4:申请通过 → 激活前自动二次调 eligibility,防 maintainer 漏检',
364
+ });
365
+ }
366
+ // 4. Iron-Rule Passkey ceremony(maintainer 真人签发)
367
+ const hpEnabled = Number(getProtocolParam('require_human_presence_for_governance_activate', 1)) === 1;
368
+ if (hpEnabled) {
369
+ if (!webauthn_token) {
370
+ return void errorRes(res, 401, 'PASSKEY_REQUIRED', 'maintainer 激活需 Iron-Rule Passkey 签发');
371
+ }
372
+ const validate = (data) => {
373
+ if (!data || typeof data !== 'object')
374
+ return false;
375
+ const d = data;
376
+ return d.application_id === application_id && d.target_user_id === app_.user_id;
377
+ };
378
+ const result = consumeGateToken(adminId, webauthn_token, 'governance_activate', validate);
379
+ if (!result.ok) {
380
+ return void errorRes(res, 401, 'PASSKEY_INVALID', `maintainer Passkey 验证失败: ${result.reason || '未知'}`);
381
+ }
382
+ }
383
+ // 5. 事务:conditional UPDATE 防竞态 + INSERT activate row + 加 users.roles
384
+ // PR #25 self-review fix P1-1:status check 进 transaction(防 2 maintainer 同时激活 → 2 activate row)
385
+ // PR #25 self-review fix P1-2:不存 note 到 appeal_resolution(语义错);maintainer note 由 logAdminAction(detail) 记
386
+ const RACE_LOST = 'RACE_LOST_PENDING_TO_ACTIVE';
387
+ try {
388
+ db.transaction(() => {
389
+ // 5.1 conditional UPDATE 原 apply row(只在 status='pending_onboarding' 时改 → 防竞态)
390
+ const updated = db.prepare("UPDATE governance_applications SET status = 'active' WHERE id = ? AND status = 'pending_onboarding'").run(app_.id);
391
+ if (updated.changes !== 1) {
392
+ // 另一 maintainer 已激活 → 抛出 sentinel,整个事务回滚
393
+ throw new Error(RACE_LOST);
394
+ }
395
+ // 5.2 INSERT 新 row(append-only audit trail,action='activate')
396
+ const activateId = generateId('gapp');
397
+ db.prepare(`
398
+ INSERT INTO governance_applications
399
+ (id, user_id, role, action, status, passkey_sig, iron_rule_method)
400
+ VALUES (?, ?, ?, 'activate', 'active', ?, 'passkey')
401
+ `).run(activateId, app_.user_id, role, webauthn_token || null);
402
+ // 5.3 加 role 到 users.roles JSON 数组(去重)
403
+ const user = db.prepare("SELECT roles FROM users WHERE id = ?").get(app_.user_id);
404
+ let roles = [];
405
+ try {
406
+ roles = JSON.parse(user?.roles || '[]');
407
+ }
408
+ catch {
409
+ roles = [];
410
+ }
411
+ if (!roles.includes(role)) {
412
+ roles.push(role);
413
+ db.prepare("UPDATE users SET roles = ? WHERE id = ?").run(JSON.stringify(roles), app_.user_id);
414
+ }
415
+ })();
416
+ }
417
+ catch (e) {
418
+ if (e.message === RACE_LOST) {
419
+ return void errorRes(res, 409, 'ALREADY_ACTIVATED', '该 application 已被其他 maintainer 激活(竞态)');
420
+ }
421
+ throw e; // 真实 SQL error → re-throw 给 express error handler
422
+ }
423
+ // 6. admin 审计 log(maintainer note 存 detail JSON,非 governance_applications 字段)
424
+ logAdminAction(adminId, 'governance_activate', 'user', app_.user_id, { role, application_id, note });
425
+ // 7. 通知 user(站内)
426
+ try {
427
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id) VALUES (?,?,?,?,?,?)`)
428
+ .run(generateId('ntf'), app_.user_id, 'governance', `🎉 你的 ${role} 申请已通过`, `你已正式上岗 ${role}。本通知由 maintainer ${adminId} 签发。详 #me 治理面板。`, null);
429
+ }
430
+ catch (_e) { /* notification 失败不阻塞 activate */ }
431
+ res.json({
432
+ success: true,
433
+ application_id,
434
+ role,
435
+ target_user_id: app_.user_id,
436
+ note: 'maintainer 激活成功,user.roles 已加 ' + role,
437
+ });
438
+ });
439
+ // ─── 阶段 4:resign(主动卸任)+ appeal(自动卸任后申诉)──────
440
+ // spec docs/GOVERNANCE-ONBOARDING.md §6.1 §7.2
441
+ // POST /api/governance/onboarding/resign — 主动卸任
442
+ // body: { role, confirm_text, webauthn_token }
443
+ // confirm_text 必须等于 'RESIGN arbitrator' 或 'RESIGN verifier'(type-to-confirm 防误触)
444
+ app.post('/api/governance/onboarding/resign', (req, res) => {
445
+ const user = auth(req, res);
446
+ if (!user)
447
+ return;
448
+ const userId = user.id;
449
+ const body = req.body || {};
450
+ const role = String(body.role || '');
451
+ const confirm_text = String(body.confirm_text || '');
452
+ const webauthn_token = body.webauthn_token ? String(body.webauthn_token) : undefined;
453
+ if (role !== 'arbitrator' && role !== 'verifier') {
454
+ return void errorRes(res, 400, 'INVALID_ROLE', "role 必须是 'arbitrator' 或 'verifier'");
455
+ }
456
+ const expectedConfirm = `RESIGN ${role}`;
457
+ if (confirm_text !== expectedConfirm) {
458
+ return void errorRes(res, 400, 'CONFIRM_MISMATCH', `请准确输入 "${expectedConfirm}" 确认卸任(type-to-confirm 防误触)`);
459
+ }
460
+ // 真源判定:users.roles JSON 包含该 role(防 1 user 多 active 行 / 防 active 行存量不一致)
461
+ const userRow = db.prepare("SELECT roles FROM users WHERE id = ?").get(userId);
462
+ let currentRoles = [];
463
+ try {
464
+ currentRoles = JSON.parse(userRow?.roles || '[]');
465
+ }
466
+ catch {
467
+ currentRoles = [];
468
+ }
469
+ if (!currentRoles.includes(role)) {
470
+ return void errorRes(res, 404, 'NOT_ACTIVE', `你当前不是 ${role},无需卸任`);
471
+ }
472
+ // 用于日志显示 + 后续 INSERT 关联(取最新的 active 行;若没有也容许,以 user.roles 为准)
473
+ const activeRow = db.prepare(`
474
+ SELECT id FROM governance_applications
475
+ WHERE user_id = ? AND role = ? AND status = 'active'
476
+ ORDER BY created_at DESC LIMIT 1
477
+ `).get(userId, role);
478
+ // active case 检查(spec §6.1:有未结案 → block,要求先 transfer/完成)
479
+ // 只对 arbitrator 适用 — verifier 投票无长期 assigned
480
+ if (role === 'arbitrator') {
481
+ // disputes.assigned_arbitrators 是 JSON 数组;ruling_type IS NULL = 未结案
482
+ const openCases = db.prepare(`
483
+ SELECT id FROM disputes
484
+ WHERE ruling_type IS NULL
485
+ AND assigned_arbitrators IS NOT NULL
486
+ AND assigned_arbitrators LIKE ?
487
+ `).all(`%"${userId}"%`);
488
+ if (openCases.length > 0) {
489
+ return void res.status(409).json({
490
+ error: '尚有未结案 dispute',
491
+ code: 'ACTIVE_CASES_EXIST',
492
+ open_case_count: openCases.length,
493
+ open_case_ids: openCases.slice(0, 10).map(c => c.id),
494
+ note: 'spec §6.1:卸任前必须先完成所有 assigned dispute 或转交(暂未实现 transfer,先完成裁决)',
495
+ });
496
+ }
497
+ }
498
+ // Iron-Rule Passkey(spec §6.1 二次验证)
499
+ const hpEnabled = Number(getProtocolParam('require_human_presence_for_governance_resign', 1)) === 1;
500
+ if (hpEnabled) {
501
+ if (!webauthn_token) {
502
+ return void errorRes(res, 401, 'PASSKEY_REQUIRED', '卸任需 Passkey 签发(spec §6.1 二次验证)');
503
+ }
504
+ const validate = (data) => {
505
+ if (!data || typeof data !== 'object')
506
+ return false;
507
+ const d = data;
508
+ return d.role === role && d.action === 'resign';
509
+ };
510
+ const result = consumeGateToken(userId, webauthn_token, 'governance_resign', validate);
511
+ if (!result.ok) {
512
+ return void errorRes(res, 401, 'PASSKEY_INVALID', `Passkey 验证失败: ${result.reason || '未知'}`);
513
+ }
514
+ }
515
+ // 冷却参数(default 30 天)
516
+ const cooldownDays = Number(getProtocolParam('governance_resign_cooldown_days', 30));
517
+ const cooldownUntil = Math.floor(Date.now() / 1000) + cooldownDays * 86400;
518
+ // 事务:conditional UPDATE users.roles(SoT) + UPDATE 所有 active row → inactive + INSERT resign
519
+ // 真源 = users.roles JSON;先去其中的 role(若中途竞态被改→ 触发 RACE_LOST)
520
+ const RACE_LOST = 'RACE_LOST_ROLE_NOT_PRESENT';
521
+ let resignId;
522
+ try {
523
+ resignId = generateId('gapp');
524
+ db.transaction(() => {
525
+ // 1. 重读 users.roles(进事务后)— 验证 role 仍存在再操作
526
+ const u = db.prepare("SELECT roles FROM users WHERE id = ?").get(userId);
527
+ let roles = [];
528
+ try {
529
+ roles = JSON.parse(u?.roles || '[]');
530
+ }
531
+ catch {
532
+ roles = [];
533
+ }
534
+ if (!roles.includes(role))
535
+ throw new Error(RACE_LOST);
536
+ roles = roles.filter(r => r !== role);
537
+ db.prepare("UPDATE users SET roles = ? WHERE id = ?").run(JSON.stringify(roles), userId);
538
+ // 2. 把所有该 user+role 的 active 行 → inactive(可能有 apply + activate 两行)
539
+ db.prepare("UPDATE governance_applications SET status = 'inactive' WHERE user_id = ? AND role = ? AND status = 'active'").run(userId, role);
540
+ // 3. INSERT resign 行(append-only audit)
541
+ db.prepare(`
542
+ INSERT INTO governance_applications
543
+ (id, user_id, role, action, status, passkey_sig, iron_rule_method, cooldown_until, source_application_id)
544
+ VALUES (?, ?, ?, 'resign', 'cooldown', ?, 'passkey', ?, ?)
545
+ `).run(resignId, userId, role, webauthn_token || null, cooldownUntil, activeRow?.id || null);
546
+ })();
547
+ }
548
+ catch (e) {
549
+ if (e.message === RACE_LOST) {
550
+ return void errorRes(res, 409, 'CONCURRENT_STATE_CHANGE', '角色状态已变化(可能已被卸任),刷新后查看');
551
+ }
552
+ throw e;
553
+ }
554
+ res.json({
555
+ success: true,
556
+ resign_application_id: resignId,
557
+ role,
558
+ cooldown_until: cooldownUntil,
559
+ cooldown_days: cooldownDays,
560
+ note: `卸任成功。${cooldownDays} 天内不能重新申请 ${role}(冷却期 spec §6.3)`,
561
+ });
562
+ });
563
+ // POST /api/governance/onboarding/appeal — auto_deactivate 后申诉
564
+ // body: { source_application_id, appeal_reason }
565
+ // 必须:source 行 action='auto_deactivate' + window 内 + 未已 appeal + reason 长度
566
+ app.post('/api/governance/onboarding/appeal', (req, res) => {
567
+ const user = auth(req, res);
568
+ if (!user)
569
+ return;
570
+ const userId = user.id;
571
+ const body = req.body || {};
572
+ const source_application_id = String(body.source_application_id || '');
573
+ const appeal_reason = String(body.appeal_reason || '').trim();
574
+ if (!source_application_id) {
575
+ return void errorRes(res, 400, 'MISSING_SOURCE', 'source_application_id 必填(指向被申诉的 auto_deactivate 行)');
576
+ }
577
+ const minChars = Number(getProtocolParam('governance_appeal_min_reason_chars', 100));
578
+ if (appeal_reason.length < minChars) {
579
+ return void errorRes(res, 400, 'REASON_TOO_SHORT', `申诉理由至少 ${minChars} 字符,当前 ${appeal_reason.length}`);
580
+ }
581
+ const source = db.prepare(`
582
+ SELECT id, user_id, role, action, status, created_at
583
+ FROM governance_applications WHERE id = ?
584
+ `).get(source_application_id);
585
+ if (!source)
586
+ return void errorRes(res, 404, 'SOURCE_NOT_FOUND', '原 application 不存在');
587
+ if (source.user_id !== userId)
588
+ return void errorRes(res, 403, 'NOT_OWNER', '不能为他人申诉');
589
+ if (source.action !== 'auto_deactivate') {
590
+ return void errorRes(res, 400, 'WRONG_SOURCE_ACTION', `只能对 auto_deactivate 行申诉,当前 action='${source.action}'`);
591
+ }
592
+ const windowDays = Number(getProtocolParam('governance_appeal_window_days', 14));
593
+ const now = Math.floor(Date.now() / 1000);
594
+ if (now - source.created_at > windowDays * 86400) {
595
+ return void errorRes(res, 400, 'APPEAL_WINDOW_EXPIRED', `申诉窗口已过期(${windowDays} 天内可申诉)`);
596
+ }
597
+ // 已 appeal 过?(防重复)
598
+ const existing = db.prepare(`
599
+ SELECT id, status FROM governance_applications
600
+ WHERE source_application_id = ? AND action = 'appeal'
601
+ ORDER BY created_at DESC LIMIT 1
602
+ `).get(source_application_id);
603
+ if (existing) {
604
+ return void errorRes(res, 409, 'APPEAL_EXISTS', `已对该 application 提交过申诉(id=${existing.id} status=${existing.status})`);
605
+ }
606
+ const id = generateId('gapp');
607
+ db.prepare(`
608
+ INSERT INTO governance_applications
609
+ (id, user_id, role, action, status, appeal_reason, source_application_id)
610
+ VALUES (?, ?, ?, 'appeal', 'pending_review', ?, ?)
611
+ `).run(id, userId, source.role, appeal_reason, source_application_id);
612
+ res.json({
613
+ success: true,
614
+ appeal_application_id: id,
615
+ status: 'pending_review',
616
+ note: 'maintainer 群将多签 review(参 CHARTER §3.2)。phase A solo 阶段由 sole maintainer 单签裁决。',
617
+ });
618
+ });
619
+ // GET /api/admin/governance/auto-deactivations — recent auto_deactivate audit
620
+ // spec §6.2 公示触发原因(透明 — 元规则 #1)
621
+ app.get('/api/admin/governance/auto-deactivations', (req, res) => {
622
+ const admin = requireGovernanceAdmin(req, res);
623
+ if (!admin)
624
+ return;
625
+ const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50));
626
+ const items = db.prepare(`
627
+ SELECT ga.id, ga.user_id, ga.role, ga.appeal_reason AS trigger_reason,
628
+ ga.cooldown_until, ga.created_at,
629
+ u.name AS user_name, u.handle,
630
+ (SELECT id FROM governance_applications WHERE source_application_id = ga.id AND action = 'appeal' ORDER BY created_at DESC LIMIT 1) AS appeal_id,
631
+ (SELECT status FROM governance_applications WHERE source_application_id = ga.id AND action = 'appeal' ORDER BY created_at DESC LIMIT 1) AS appeal_status
632
+ FROM governance_applications ga
633
+ JOIN users u ON u.id = ga.user_id
634
+ WHERE ga.action = 'auto_deactivate'
635
+ ORDER BY ga.created_at DESC
636
+ LIMIT ?
637
+ `).all(limit);
638
+ res.json({ items, count: items.length });
639
+ });
640
+ // GET /api/admin/governance/appeals — maintainer 看待裁决申诉
641
+ app.get('/api/admin/governance/appeals', (req, res) => {
642
+ const admin = requireGovernanceAdmin(req, res);
643
+ if (!admin)
644
+ return;
645
+ const items = db.prepare(`
646
+ SELECT ga.id, ga.user_id, ga.role, ga.appeal_reason, ga.source_application_id, ga.created_at,
647
+ u.name AS user_name, u.handle, u.email,
648
+ src.created_at AS auto_deactivate_at
649
+ FROM governance_applications ga
650
+ JOIN users u ON u.id = ga.user_id
651
+ LEFT JOIN governance_applications src ON src.id = ga.source_application_id
652
+ WHERE ga.action = 'appeal' AND ga.status = 'pending_review'
653
+ ORDER BY ga.created_at ASC
654
+ LIMIT 100
655
+ `).all();
656
+ res.json({ items, count: items.length });
657
+ });
658
+ // POST /api/admin/governance/resolve-appeal — maintainer 裁决申诉
659
+ // body: { appeal_application_id, decision: 'accept' | 'reject', resolution_text, webauthn_token }
660
+ // accept → 恢复 active(spec §7.2) ;reject → 维持 inactive,公开理由
661
+ app.post('/api/admin/governance/resolve-appeal', (req, res) => {
662
+ const admin = requireGovernanceAdmin(req, res);
663
+ if (!admin)
664
+ return;
665
+ const adminId = admin.id;
666
+ const body = req.body || {};
667
+ const appeal_application_id = String(body.appeal_application_id || '');
668
+ const decision = String(body.decision || '');
669
+ const resolution_text = String(body.resolution_text || '').trim();
670
+ const webauthn_token = body.webauthn_token ? String(body.webauthn_token) : undefined;
671
+ if (!appeal_application_id)
672
+ return void errorRes(res, 400, 'MISSING_APPEAL_ID', 'appeal_application_id 必填');
673
+ if (decision !== 'accept' && decision !== 'reject') {
674
+ return void errorRes(res, 400, 'INVALID_DECISION', "decision 必须是 'accept' 或 'reject'");
675
+ }
676
+ if (resolution_text.length < 30) {
677
+ return void errorRes(res, 400, 'RESOLUTION_TOO_SHORT', '处置理由至少 30 字符(spec §7.2 公开理由)');
678
+ }
679
+ const appeal = db.prepare(`
680
+ SELECT id, user_id, role, action, status, source_application_id
681
+ FROM governance_applications WHERE id = ?
682
+ `).get(appeal_application_id);
683
+ if (!appeal)
684
+ return void errorRes(res, 404, 'NOT_FOUND', 'appeal 不存在');
685
+ if (appeal.action !== 'appeal')
686
+ return void errorRes(res, 400, 'NOT_APPEAL', `action='${appeal.action}',非 appeal 行`);
687
+ if (appeal.status !== 'pending_review')
688
+ return void errorRes(res, 409, 'ALREADY_RESOLVED', `appeal 已处置(status='${appeal.status}')`);
689
+ // Iron-Rule Passkey(maintainer 决策需真人门)
690
+ const hpEnabled = Number(getProtocolParam('require_human_presence_for_governance_appeal_resolve', 1)) === 1;
691
+ if (hpEnabled) {
692
+ if (!webauthn_token)
693
+ return void errorRes(res, 401, 'PASSKEY_REQUIRED', 'maintainer 裁决申诉需 Passkey 签发');
694
+ const validate = (data) => {
695
+ if (!data || typeof data !== 'object')
696
+ return false;
697
+ const d = data;
698
+ return d.appeal_application_id === appeal_application_id && d.decision === decision;
699
+ };
700
+ const result = consumeGateToken(adminId, webauthn_token, 'governance_appeal_resolve', validate);
701
+ if (!result.ok) {
702
+ return void errorRes(res, 401, 'PASSKEY_INVALID', `Passkey 验证失败: ${result.reason || '未知'}`);
703
+ }
704
+ }
705
+ const RACE_LOST = 'RACE_LOST_APPEAL_RESOLVE';
706
+ const newStatus = decision === 'accept' ? 'accepted' : 'rejected';
707
+ try {
708
+ db.transaction(() => {
709
+ // conditional UPDATE appeal row(防双 maintainer 同时处置)
710
+ const updated = db.prepare("UPDATE governance_applications SET status = ?, appeal_resolution = ? WHERE id = ? AND status = 'pending_review'").run(newStatus, resolution_text, appeal_application_id);
711
+ if (updated.changes !== 1)
712
+ throw new Error(RACE_LOST);
713
+ if (decision === 'accept') {
714
+ // 恢复 active:新插 row + UPDATE 原 auto_deactivate 行的 cooldown_until 清空(允许立即恢复)
715
+ // 同时 users.roles 加回 role
716
+ const restoreId = generateId('gapp');
717
+ db.prepare(`
718
+ INSERT INTO governance_applications
719
+ (id, user_id, role, action, status, source_application_id)
720
+ VALUES (?, ?, ?, 'restore', 'active', ?)
721
+ `).run(restoreId, appeal.user_id, appeal.role, appeal.source_application_id);
722
+ const u = db.prepare("SELECT roles FROM users WHERE id = ?").get(appeal.user_id);
723
+ let roles = [];
724
+ try {
725
+ roles = JSON.parse(u?.roles || '[]');
726
+ }
727
+ catch {
728
+ roles = [];
729
+ }
730
+ if (!roles.includes(appeal.role)) {
731
+ roles.push(appeal.role);
732
+ db.prepare("UPDATE users SET roles = ? WHERE id = ?").run(JSON.stringify(roles), appeal.user_id);
733
+ }
734
+ }
735
+ })();
736
+ }
737
+ catch (e) {
738
+ if (e.message === RACE_LOST) {
739
+ return void errorRes(res, 409, 'CONCURRENT_RESOLUTION', 'appeal 已被其他 maintainer 处置(竞态)');
740
+ }
741
+ throw e;
742
+ }
743
+ logAdminAction(adminId, 'governance_resolve_appeal', 'user', appeal.user_id, {
744
+ role: appeal.role, appeal_id: appeal_application_id, decision, resolution_text,
745
+ });
746
+ // 通知 user
747
+ try {
748
+ const title = decision === 'accept'
749
+ ? `✅ 你的 ${appeal.role} 申诉已通过`
750
+ : `❌ 你的 ${appeal.role} 申诉被驳回`;
751
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id) VALUES (?,?,?,?,?,?)`)
752
+ .run(generateId('ntf'), appeal.user_id, 'governance', title, resolution_text, null);
753
+ }
754
+ catch (_e) { /* ignore */ }
755
+ res.json({
756
+ success: true,
757
+ appeal_application_id,
758
+ decision,
759
+ new_status: newStatus,
760
+ note: decision === 'accept' ? 'user.roles 已恢复 ' + appeal.role : '维持 inactive 状态',
761
+ });
762
+ });
763
+ // GET /api/governance/onboarding/progress
764
+ // 返回 onboarding 整体进度(spec §4):申请状态 + 学习包(client localStorage) + 题目分数 + 案例(后续)
765
+ app.get('/api/governance/onboarding/progress', (req, res) => {
766
+ const user = auth(req, res);
767
+ if (!user)
768
+ return;
769
+ const userId = user.id;
770
+ // 各 role 最新 application
771
+ const applications = db.prepare(`
772
+ SELECT id, role, action, status, quiz_score, quiz_passed_at, case_review_text, cooldown_until, created_at
773
+ FROM governance_applications
774
+ WHERE user_id = ?
775
+ ORDER BY created_at DESC
776
+ `).all(userId);
777
+ const param = db.prepare("SELECT value FROM protocol_params WHERE key = ?").get('governance_onboarding.quiz_pass_score');
778
+ const passThreshold = param ? Number(param.value) : 80;
779
+ res.json({
780
+ applications,
781
+ pass_threshold: passThreshold,
782
+ disclosure_version: GOVERNANCE_APPLY_DISCLOSURE_VERSION,
783
+ });
784
+ });
785
+ }