@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
|
@@ -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
|
+
}
|